diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 538294c..908fb21 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -141,6 +141,7 @@ jobs:
mkdir -p dist/wp-branches-for-post
rsync -a ./ dist/wp-branches-for-post/ \
--exclude='.git' \
+ --exclude='.gitignore' \
--exclude='.github' \
--exclude='.wordpress-org' \
--exclude='node_modules' \
diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml
index 77d5348..c2c1c1a 100644
--- a/.github/workflows/compatibility.yml
+++ b/.github/workflows/compatibility.yml
@@ -74,7 +74,7 @@ jobs:
--admin_email=admin@example.test
ln -s "$GITHUB_WORKSPACE" /tmp/wordpress/wp-content/plugins/wp-branches-for-post
wp plugin activate wp-branches-for-post --path=/tmp/wordpress
- - name: Run 104-check real WordPress integration suite
+ - name: Run real WordPress integration suite
run: wp eval-file "$GITHUB_WORKSPACE/tests/wp-real-2.1.php" --path=/tmp/wordpress
release-zip:
@@ -116,6 +116,7 @@ jobs:
done
forbidden=(
'/\.git/'
+ '/\.gitignore$'
'/\.github/'
'/\.wordpress-org/'
'/node_modules/'
diff --git a/README.md b/README.md
index f46a0c5..fbcd1c2 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
WP Branches For Post lets editors create an isolated working branch from an existing WordPress post, page, or supported custom post type, edit without changing the public original, review the difference against a saved baseline, and merge safely when the work is ready.
-Version **2.1.0** turns the plugin into a true three-way editorial workflow rather than a simple duplicate-and-replace tool.
+Version **2.1.1** builds on the three-way editorial workflow with clearer merge review values, stronger branch management, tighter privacy boundaries, and broader release validation.
@@ -30,7 +30,7 @@ This plugin models the problem explicitly:
The result is an editorial workflow closer to source control, while still using normal WordPress posts, permissions, metadata, taxonomies, and editors.
-## What 2.1.0 can do
+## What 2.1.1 can do
- Create an isolated draft branch from published, private, or scheduled content.
- Review **Base / Original / Branch** before merging.
@@ -84,12 +84,12 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) and [SECURITY.md](./SECURITY.md) for th
## Validation depth
-The 2.1.0 release was promoted only after the same candidate passed the following gates:
+The 2.1.1 release extends the 2.1.0 validation baseline and has passed the following gates:
| Layer | Release validation |
| --- | --- |
-| Real WordPress service/integration suite | **104 / 104 checks** |
-| Real Chromium + Gutenberg E2E | **33 / 33 checks** |
+| Real WordPress service/integration suite | **130 / 130 checks** |
+| Real Chromium + Gutenberg E2E | **42 / 42 checks** |
| Semantic UI/tutorial coverage | **35 / 35 cases** |
| WordPress compatibility | **6.6 + 7.1.1** |
| PHP compatibility | **8.2 / 8.3 / 8.4** |
diff --git a/README.txt b/README.txt
index 1c6ba91..3354701 100644
--- a/README.txt
+++ b/README.txt
@@ -4,7 +4,7 @@ Tags: post branch, editorial workflow, staging, revision, gutenberg
Requires at least: 6.6
Tested up to: 7.1
Requires PHP: 8.2
-Stable tag: 2.1.0
+Stable tag: 2.1.1
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@@ -267,6 +267,18 @@ Human-readable source code and build tooling are maintained at https://github.co
== Changelog ==
+= 2.1.1 =
+* Improved merge review with readable Base / Original / Branch values for menu order, comment status, ping status and password state.
+* Added readable taxonomy term names and featured-image identity with thumbnail previews to the three-way review.
+* Kept post passwords masked and arbitrary custom-meta values private in the REST review payload.
+* Expanded Existing branches cards with branch ID, creator, modified time, state, change count and conflict count.
+* Added dedicated Existing branches views to the Posts and Pages list tables, including legacy branch relationships.
+* Kept branch-list counts and filtering aligned with WordPress author capabilities so users without edit_others_posts see only their own branches.
+* Prevented private original titles from being exposed in branch list states when the current user cannot read the original.
+* Localized readable Open / Closed review values through WordPress core translations.
+* Expanded real WordPress integration coverage to 130 checks and real Gutenberg browser coverage to 42 checks, while retaining the 35-case semantic UI/tutorial contract.
+* Tightened release-tree and ZIP hygiene so development hidden files remain outside the distributable plugin.
+
= 2.1.0 =
* Added a three-way branch/original/baseline review instead of treating every newer original edit as the same kind of conflict.
* Added detailed branch changes, original changes, exact conflicts and preserved identity changes to the Block Editor merge review.
diff --git a/TESTING.md b/TESTING.md
index cadac74..0798854 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -9,7 +9,8 @@ The disposable CI laboratory is:
- Repository: `time2analyze/wp-branch`
- Branch: `main`
- Source repository: `hsxk/WP-Branches-For-Post`
-- 2.1 development source: `feat/2.1-merge-review-safety`
+- 2.1 baseline source: `feat/2.1-merge-review-safety`
+- 2.1.1 release-candidate source: `feat/2.1.1-review-branch-management`
- Lab workflow: `.github/workflows/lab-full-validation.yml`
The lab repository uses the second GitHub account so validation does not consume the Actions quota of the formal plugin repository.
@@ -43,7 +44,7 @@ The release candidate must report the same version in all public package metadat
- `README.txt` Stable tag
- `package.json`
-For the 2.1 release candidate these must all be `2.1.0`.
+For the 2.1.1 release these values are all `2.1.1`. Any future release must advance all four values together and rerun the complete release validation before tagging.
### JavaScript and CSS
@@ -92,6 +93,8 @@ wp eval-file wp-content/plugins/wp-branches-for-post/tests/wp-real-2.1.php
The suite exercises real WordPress posts, metadata, taxonomies, users, capabilities, REST objects and the plugin's service classes. Test data is deleted at the end.
+The 2.1.1 release passes **130 / 130** checks across WordPress 6.6 / PHP 8.2, WordPress 7.1.1 / PHP 8.2, and WordPress 7.1.1 / PHP 8.4.
+
Important covered scenarios include branch creation, forced-draft protection, three-way merge, non-conflicting original changes, rebase, real conflicts, force-merge permission checks, hierarchical URL identity preservation, rollback after injected write failures, synced-pattern detection, REST review payloads, legacy compatibility and discard behavior.
## Real Gutenberg browser test
@@ -119,6 +122,8 @@ The browser test verifies the real admin/editor experience:
11. Return to the original.
12. Verify the merged branch content is present.
+The current 2.1.1 browser contract contains **42 / 42** passing checks, including the Existing branches list view, branch-card metadata, stale-review protection, rebase, force-merge confirmation and discard behavior.
+
This is a real browser + WordPress + MySQL test, not a DOM fixture or mocked REST test.
## Screenshots
diff --git a/assets/css/editor.css b/assets/css/editor.css
index dacd354..699661f 100644
--- a/assets/css/editor.css
+++ b/assets/css/editor.css
@@ -35,9 +35,33 @@
margin-bottom: 5px;
}
-.wbfp-branch-card__head > a {
+.wbfp-branch-card__identity {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.wbfp-branch-card__identity > a {
font-weight: 600;
text-decoration: none;
+ overflow-wrap: anywhere;
+}
+
+.wbfp-branch-card__id {
+ color: #646970;
+ font-size: 11px;
+}
+
+.wbfp-branch-card__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 10px;
+}
+
+.wbfp-branch-card__meta > span + span::before {
+ content: "·";
+ margin-right: 10px;
+ color: #a7aaad;
}
.wbfp-meta {
@@ -155,6 +179,16 @@
text-transform: uppercase;
}
+.wbfp-compare__thumbnail {
+ display: block;
+ width: 72px;
+ height: 72px;
+ margin: 2px 0 8px;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+ object-fit: cover;
+}
+
.wbfp-compare__version pre {
max-height: 180px;
margin: 0;
diff --git a/build/index.asset.php b/build/index.asset.php
index e32b821..6616b18 100644
--- a/build/index.asset.php
+++ b/build/index.asset.php
@@ -8,5 +8,5 @@
'wp-i18n',
'wp-plugins'
),
- 'version' => 'a2856ab2d5e1df34f1e6'
+ 'version' => 'c22cc7f1a4434d352365'
);
diff --git a/build/index.js b/build/index.js
index cdd5b44..59d07d0 100644
--- a/build/index.js
+++ b/build/index.js
@@ -1,8 +1,9 @@
-(()=>{"use strict";const e={n:s=>{const t=s&&s.__esModule?()=>s.default:()=>s;return e.d(t,{a:t}),t},d:(s,t)=>{for(var r in t)e.o(t,r)&&!e.o(s,r)&&Object.defineProperty(s,r,{enumerable:!0,get:t[r]})},o:(e,s)=>Object.hasOwn(e,s)},s=window.wp.apiFetch;var t=e.n(s);const r=window.wp.components,a=window.wp.data,n=window.wp.editor,o=window.wp.element,i=window.wp.i18n,c=window.wp.plugins,p=o.createElement,l=e=>e?.message||(0,i.__)("WP Branches For Post could not complete the request.","wp-branches-for-post"),h=e=>({clean:(0,i.__)("Ready","wp-branches-for-post"),informational:(0,i.__)("Ready · original identity changed","wp-branches-for-post"),rebase_available:(0,i.__)("Original updated · safe to refresh branch","wp-branches-for-post"),conflict:(0,i.__)("Needs conflict review","wp-branches-for-post"),changed:(0,i.__)("Original changed","wp-branches-for-post"),unknown:(0,i.__)("Legacy branch","wp-branches-for-post"),missing:(0,i.__)("Original unavailable","wp-branches-for-post")}[e]||e);function _({title:e,paths:s,danger:t=!1}){return s?.length?p("div",{className:t?"wbfp-review-group wbfp-review-group--danger":"wbfp-review-group"},p("strong",null,e),p("ul",null,s.map(e=>p("li",{key:e},(e=>{const s={"post.post_title":(0,i.__)("Title","wp-branches-for-post"),"post.post_content":(0,i.__)("Content","wp-branches-for-post"),"post.post_excerpt":(0,i.__)("Excerpt","wp-branches-for-post"),"post.menu_order":(0,i.__)("Menu order","wp-branches-for-post"),"post.comment_status":(0,i.__)("Comment status","wp-branches-for-post"),"post.ping_status":(0,i.__)("Ping status","wp-branches-for-post"),"post.post_password":(0,i.__)("Password","wp-branches-for-post"),"identity.post_status":(0,i.__)("Publication status","wp-branches-for-post"),"identity.post_name":(0,i.__)("Slug","wp-branches-for-post"),"identity.post_author":(0,i.__)("Author","wp-branches-for-post"),"identity.post_parent":(0,i.__)("Parent","wp-branches-for-post"),"identity.post_type":(0,i.__)("Post type","wp-branches-for-post")};return s[e]?s[e]:e.startsWith("meta.")?(0,i.sprintf)(/* translators: %s: post meta key. */ /* translators: %s: post meta key. */
-(0,i.__)("Metadata: %s","wp-branches-for-post"),e.slice(5)):e.startsWith("taxonomies.")?(0,i.sprintf)(/* translators: %s: taxonomy slug. */ /* translators: %s: taxonomy slug. */
-(0,i.__)("Taxonomy: %s","wp-branches-for-post"),e.slice(11)):e})(e))))):null}function b({values:e}){if(!e||!Object.keys(e).length)return null;const s={post_title:(0,i.__)("Title","wp-branches-for-post"),post_excerpt:(0,i.__)("Excerpt","wp-branches-for-post"),post_content:(0,i.__)("Content","wp-branches-for-post")};return p("div",{className:"wbfp-compare"},p("strong",null,(0,i.__)("Three-way comparison","wp-branches-for-post")),...Object.entries(e).map(([e,t])=>p("details",{key:e,className:"wbfp-compare__field"},p("summary",null,s[e]||e),p("div",{className:"wbfp-compare__version"},p("span",null,(0,i.__)("Base","wp-branches-for-post")),p("pre",null,t.base||"—")),p("div",{className:"wbfp-compare__version"},p("span",null,(0,i.__)("Original now","wp-branches-for-post")),p("pre",null,t.original||"—")),p("div",{className:"wbfp-compare__version"},p("span",null,(0,i.__)("Branch now","wp-branches-for-post")),p("pre",null,t.branch||"—")))))}(0,c.registerPlugin)("wp-branches-for-post",{render:function(){const e=(0,a.useSelect)(e=>{const s=e("core/editor");return{postId:s.getCurrentPostId(),dirty:s.isEditedPostDirty(),saving:s.isSavingPost()}},[]),{savePost:s}=(0,a.useDispatch)("core/editor"),[c,w]=(0,o.useState)(null),[f,d]=(0,o.useState)(!1),[g,u]=(0,o.useState)(""),[m,v]=(0,o.useState)(!1),[y,N]=(0,o.useState)(null),k=async()=>{if(!e.postId)return null;const s=await t()({path:`/wbfp/v1/posts/${e.postId}/status`});return w(s),s};(0,o.useEffect)(()=>{if(!e.postId)return;let s=!0;return t()({path:`/wbfp/v1/posts/${e.postId}/status`}).then(e=>s&&w(e)).catch(e=>s&&u(l(e))),()=>{s=!1}},[e.postId]);const T=async()=>{e.dirty&&await s()},P=async()=>{if(e.dirty)u((0,i.__)("Save or discard the current edits to the original before creating a branch. This prevents unpublished editor changes from being mistaken for the branch baseline.","wp-branches-for-post"));else{d(!0),u("");try{const s=await t()({path:`/wbfp/v1/posts/${e.postId}/branches`,method:"POST"});window.location.assign(s.edit_url)}catch(e){u(l(e)),d(!1)}}},C=async()=>{if(m)v(!1);else{d(!0),u("");try{"branch"===c?.type&&(await T(),await k()),v(!0)}catch(e){u(l(e))}finally{d(!1)}}},S=async()=>{d(!0),u("");try{await T(),await t()({path:`/wbfp/v1/branches/${e.postId}/rebase`,method:"POST"}),window.location.reload()}catch(e){u(l(e))}finally{d(!1)}},D=async(s=!1)=>{const r=c?.review_token||"";d(!0),u("");try{await T();const a=await k(),n=!r||!a?.review_token||a.review_token!==r,o=["conflict","changed","unknown","missing"].includes(a?.conflict);if(n||o&&!s)return v(!0),void u((0,i.__)("The review state changed after saving. Review the latest conflicts before merging.","wp-branches-for-post"));const c=await t()({path:`/wbfp/v1/branches/${e.postId}/merge`,method:"POST",data:{force:s,review_token:a.review_token}});window.location.assign(c.edit_url)}catch(e){u(l(e))}finally{d(!1)}};let O=p(r.Spinner);if("original"===c?.type){const s=c.branches?.map(e=>p("li",{key:e.id,className:"wbfp-branch-card"},p("div",{className:"wbfp-branch-card__head"},p("a",{href:e.edit_url},e.title||(0,i.sprintf)(/* translators: %d: branch post ID. */ /* translators: %d: branch post ID. */
-(0,i.__)("Branch #%d","wp-branches-for-post"),e.id)),p("span",{className:`wbfp-state wbfp-state--${e.conflict}`},h(e.conflict))),e.creator&&p("div",{className:"wbfp-meta"},(0,i.sprintf)(/* translators: %s: branch creator display name. */ /* translators: %s: branch creator display name. */
-(0,i.__)("Created by %s","wp-branches-for-post"),e.creator)),p("div",{className:"wbfp-meta"},(0,i.sprintf)(/* translators: 1: number of branch changes, 2: number of conflicts. */ /* translators: 1: number of branch changes, 2: number of conflicts. */
-(0,i.__)("%1$d branch changes · %2$d conflicts","wp-branches-for-post"),e.branch_changes||0,e.conflicts||0))));O=p(o.Fragment,null,p("p",null,(0,i.__)("Edit safely in a separate draft. The published post stays unchanged until you merge the branch.","wp-branches-for-post")),e.dirty&&p(r.Notice,{status:"warning",isDismissible:!1},(0,i.__)("This original has unsaved editor changes. Save or discard them before creating a branch so the baseline matches the saved public post.","wp-branches-for-post")),c.can_create&&p(r.Button,{variant:"primary",onClick:P,disabled:f||e.dirty||e.saving},f?(0,i.__)("Creating…","wp-branches-for-post"):(0,i.__)("Create branch","wp-branches-for-post")),c.branches?.length>0&&p("div",{className:"wbfp-existing-branches"},p("strong",null,(0,i.__)("Existing branches","wp-branches-for-post")),p("ul",null,s)))}else if("branch"===c?.type){const s=c.conflict,t=c.review||{},a=["conflict","changed","unknown","missing"].includes(s),n=c.legacy||(t.branch_changes?.length||0)>0,l=m&&p("div",{className:"wbfp-review"},p("div",{className:"wbfp-review__title"},(0,i.__)("Merge review","wp-branches-for-post")),c.legacy&&p(r.Notice,{status:"warning",isDismissible:!1},(0,i.__)("This branch predates three-way snapshots, so detailed change comparison is unavailable. Force merge only after manually reviewing the original.","wp-branches-for-post")),p(_,{title:(0,i.__)("Branch changes","wp-branches-for-post"),paths:t.branch_changes}),p(_,{title:(0,i.__)("Original changes since branch creation","wp-branches-for-post"),paths:t.original_changes}),p(_,{title:(0,i.__)("Conflicts","wp-branches-for-post"),paths:t.conflicts,danger:!0}),p(_,{title:(0,i.__)("Original identity changes preserved by merge","wp-branches-for-post"),paths:t.informational_changes}),p(b,{values:c.review_values}),!c.legacy&&!t.branch_changes?.length&&p("p",{className:"wbfp-meta"},(0,i.__)("No mergeable branch changes are currently detected.","wp-branches-for-post")),p("div",{className:"wbfp-review__actions"},c.can_merge&&!a&&n&&p(r.Button,{variant:"primary",onClick:()=>D(!1),disabled:f||e.saving},f?(0,i.__)("Saving and merging…","wp-branches-for-post"):(0,i.__)("Save & merge into original","wp-branches-for-post")),c.can_merge&&a&&c.can_force_merge&&"missing"!==s&&p(r.Button,{variant:"secondary",isDestructive:!0,onClick:()=>N("force"),disabled:f||e.saving},f?(0,i.__)("Saving and merging…","wp-branches-for-post"):(0,i.__)("Force merge reviewed conflicts","wp-branches-for-post"))));let w=null;"rebase_available"===s?w=p(r.Notice,{status:"info",isDismissible:!1},(0,i.__)("The original has newer, non-conflicting changes. You can update this branch first, or review and merge while preserving those original changes.","wp-branches-for-post")):"informational"===s?w=p(r.Notice,{status:"info",isDismissible:!1},(0,i.__)("The original identity changed (for example status, slug, author, or parent). Those changes are preserved and are not overwritten by this branch.","wp-branches-for-post")):"conflict"===s?w=p(r.Notice,{status:"warning",isDismissible:!1},(0,i.__)("The branch and original changed the same data differently. Review the conflicts before deciding whether to force merge.","wp-branches-for-post")):"changed"===s||"unknown"===s?w=p(r.Notice,{status:"warning",isDismissible:!1},(0,i.__)("This older branch cannot provide a precise three-way comparison. Review the original manually before forcing a merge.","wp-branches-for-post")):"missing"===s&&(w=p(r.Notice,{status:"error",isDismissible:!1},(0,i.__)("The original post is unavailable. This branch cannot be merged.","wp-branches-for-post"))),O=p(o.Fragment,null,p("p",null,(0,i.__)("This draft is isolated from the public original.","wp-branches-for-post"),c.original_edit_url&&p(o.Fragment,null," ",p("a",{href:c.original_edit_url},(0,i.__)("Open original","wp-branches-for-post")))),c.creator&&p("p",{className:"wbfp-meta"},(0,i.sprintf)(/* translators: %s: branch creator display name. */ /* translators: %s: branch creator display name. */
-(0,i.__)("Created by %s","wp-branches-for-post"),c.creator)),p("div",{className:`wbfp-state wbfp-state--${s}`},h(s)),c.synced_pattern_count>0&&p(r.Notice,{status:"warning",isDismissible:!1},(0,i.sprintf)(/* translators: %d: number of synced patterns referenced by the branch. */ /* translators: %d: number of synced patterns referenced by the branch. */
-(0,i.__)("This branch references %d synced pattern(s). Editing a synced pattern is global in WordPress and is not isolated by the branch.","wp-branches-for-post"),c.synced_pattern_count)),w,p("div",{className:"wbfp-preview-links"},c.branch_preview_url&&p("a",{href:c.branch_preview_url,target:"_blank",rel:"noreferrer"},(0,i.__)("Preview branch","wp-branches-for-post")),c.original_preview_url&&p("a",{href:c.original_preview_url,target:"_blank",rel:"noreferrer"},(0,i.__)("View original","wp-branches-for-post"))),c.can_rebase&&p(r.Button,{variant:"secondary",onClick:S,disabled:f||e.saving},f?(0,i.__)("Updating…","wp-branches-for-post"):(0,i.__)("Update branch from original","wp-branches-for-post")),p("div",{className:"wbfp-actions"},p(r.Button,{variant:m?"secondary":"primary",onClick:C,disabled:f},m?(0,i.__)("Hide merge review","wp-branches-for-post"):(0,i.__)("Review changes","wp-branches-for-post")),c.can_discard&&p(r.Button,{variant:"tertiary",isDestructive:!0,onClick:()=>N("discard"),disabled:f},(0,i.__)("Discard branch","wp-branches-for-post"))),l)}const B=y&&p(r.Modal,{title:"force"===y?(0,i.__)("Force merge conflicts?","wp-branches-for-post"):(0,i.__)("Discard this branch?","wp-branches-for-post"),onRequestClose:()=>N(null)},p("p",null,"force"===y?(0,i.__)("Force merge will prefer branch values for the listed conflicts. Non-conflicting newer work on the original is still preserved.","wp-branches-for-post"):(0,i.__)("The branch will be moved to Trash. The public original will not be changed.","wp-branches-for-post")),p("div",{className:"wbfp-confirm-actions"},p(r.Button,{variant:"secondary",onClick:()=>N(null)},(0,i.__)("Cancel","wp-branches-for-post")),p(r.Button,{variant:"primary",isDestructive:!0,onClick:()=>{const s=y;N(null),"force"===s?D(!0):(async()=>{d(!0),u("");try{const s=await t()({path:`/wbfp/v1/branches/${e.postId}`,method:"DELETE"});window.location.assign(s.edit_url||"edit.php")}catch(e){u(l(e)),d(!1)}})()}},"force"===y?(0,i.__)("Force merge","wp-branches-for-post"):(0,i.__)("Move to Trash","wp-branches-for-post"))));return p(n.PluginDocumentSettingPanel,{name:"wbfp-branch",title:(0,i.__)("Post Branch","wp-branches-for-post")},g&&p(r.Notice,{status:"error",onRemove:()=>u("")},g),O,B)}})})();
\ No newline at end of file
+(()=>{"use strict";const e={n:a=>{const r=a&&a.__esModule?()=>a.default:()=>a;return e.d(r,{a:r}),r},d:(a,r)=>{for(var s in r)e.o(r,s)&&!e.o(a,s)&&Object.defineProperty(a,s,{enumerable:!0,get:r[s]})},o:(e,a)=>Object.hasOwn(e,a)},a=window.wp.apiFetch;var r=e.n(a);const s=window.wp.components,t=window.wp.data,n=window.wp.editor,i=window.wp.element,o=window.wp.i18n,c=window.wp.plugins,p=i.createElement,l=e=>e?.message||(0,o.__)("WP Branches For Post could not complete the request.","wp-branches-for-post"),h=e=>{const a={"post.post_title":(0,o.__)("Title","wp-branches-for-post"),"post.post_content":(0,o.__)("Content","wp-branches-for-post"),"post.post_excerpt":(0,o.__)("Excerpt","wp-branches-for-post"),"post.menu_order":(0,o.__)("Menu order","wp-branches-for-post"),"post.comment_status":(0,o.__)("Comment status","wp-branches-for-post"),"post.ping_status":(0,o.__)("Ping status","wp-branches-for-post"),"post.post_password":(0,o.__)("Password","wp-branches-for-post"),"identity.post_status":(0,o.__)("Publication status","wp-branches-for-post"),"identity.post_name":(0,o.__)("Slug","wp-branches-for-post"),"identity.post_author":(0,o.__)("Author","wp-branches-for-post"),"identity.post_parent":(0,o.__)("Parent","wp-branches-for-post"),"identity.post_type":(0,o.__)("Post type","wp-branches-for-post")};return a[e]?a[e]:e.startsWith("meta.")?(0,o.sprintf)(/* translators: %s: post meta key. */ /* translators: %s: post meta key. */
+(0,o.__)("Metadata: %s","wp-branches-for-post"),e.slice(5)):e.startsWith("taxonomies.")?(0,o.sprintf)(/* translators: %s: taxonomy slug. */ /* translators: %s: taxonomy slug. */
+(0,o.__)("Taxonomy: %s","wp-branches-for-post"),e.slice(11)):e},_=e=>({clean:(0,o.__)("Ready","wp-branches-for-post"),informational:(0,o.__)("Ready · original identity changed","wp-branches-for-post"),rebase_available:(0,o.__)("Original updated · safe to refresh branch","wp-branches-for-post"),conflict:(0,o.__)("Needs conflict review","wp-branches-for-post"),changed:(0,o.__)("Original changed","wp-branches-for-post"),unknown:(0,o.__)("Legacy branch","wp-branches-for-post"),missing:(0,o.__)("Original unavailable","wp-branches-for-post")}[e]||e);function b({title:e,paths:a,danger:r=!1}){return a?.length?p("div",{className:r?"wbfp-review-group wbfp-review-group--danger":"wbfp-review-group"},p("strong",null,e),p("ul",null,a.map(e=>p("li",{key:e},h(e))))):null}function w({values:e}){if(!e||!Object.keys(e).length)return null;const a=(e,a,r)=>p("div",{className:"wbfp-compare__version"},p("span",null,e),r&&p("img",{className:"wbfp-compare__thumbnail",src:r,alt:""}),p("pre",null,a||"—"));return p("div",{className:"wbfp-compare"},p("strong",null,(0,o.__)("Three-way comparison","wp-branches-for-post")),...Object.entries(e).map(([e,r])=>p("details",{key:e,className:"wbfp-compare__field"},p("summary",null,r.label||h(e)),a((0,o.__)("Base","wp-branches-for-post"),r.base,r.base_preview_url),a((0,o.__)("Original now","wp-branches-for-post"),r.original,r.original_preview_url),a((0,o.__)("Branch now","wp-branches-for-post"),r.branch,r.branch_preview_url))))}(0,c.registerPlugin)("wp-branches-for-post",{render:function(){const e=(0,t.useSelect)(e=>{const a=e("core/editor");return{postId:a.getCurrentPostId(),dirty:a.isEditedPostDirty(),saving:a.isSavingPost()}},[]),{savePost:a}=(0,t.useDispatch)("core/editor"),[c,h]=(0,i.useState)(null),[d,f]=(0,i.useState)(!1),[g,u]=(0,i.useState)(""),[m,v]=(0,i.useState)(!1),[y,N]=(0,i.useState)(null),k=async()=>{if(!e.postId)return null;const a=await r()({path:`/wbfp/v1/posts/${e.postId}/status`});return h(a),a};(0,i.useEffect)(()=>{if(!e.postId)return;let a=!0;return r()({path:`/wbfp/v1/posts/${e.postId}/status`}).then(e=>a&&h(e)).catch(e=>a&&u(l(e))),()=>{a=!1}},[e.postId]);const T=async()=>{e.dirty&&await a()},P=async()=>{if(e.dirty)u((0,o.__)("Save or discard the current edits to the original before creating a branch. This prevents unpublished editor changes from being mistaken for the branch baseline.","wp-branches-for-post"));else{f(!0),u("");try{const a=await r()({path:`/wbfp/v1/posts/${e.postId}/branches`,method:"POST"});window.location.assign(a.edit_url)}catch(e){u(l(e)),f(!1)}}},C=async()=>{if(m)v(!1);else{f(!0),u("");try{"branch"===c?.type&&(await T(),await k()),v(!0)}catch(e){u(l(e))}finally{f(!1)}}},S=async()=>{f(!0),u("");try{await T(),await r()({path:`/wbfp/v1/branches/${e.postId}/rebase`,method:"POST"}),window.location.reload()}catch(e){u(l(e))}finally{f(!1)}},D=async(a=!1)=>{const s=c?.review_token||"";f(!0),u("");try{await T();const t=await k(),n=!s||!t?.review_token||t.review_token!==s,i=["conflict","changed","unknown","missing"].includes(t?.conflict);if(n||i&&!a)return v(!0),void u((0,o.__)("The review state changed after saving. Review the latest conflicts before merging.","wp-branches-for-post"));const c=await r()({path:`/wbfp/v1/branches/${e.postId}/merge`,method:"POST",data:{force:a,review_token:t.review_token}});window.location.assign(c.edit_url)}catch(e){u(l(e))}finally{f(!1)}};let B=p(s.Spinner);if("original"===c?.type){const a=c.branches?.map(e=>p("li",{key:e.id,className:"wbfp-branch-card"},p("div",{className:"wbfp-branch-card__head"},p("div",{className:"wbfp-branch-card__identity"},p("a",{href:e.edit_url},e.title||(0,o.sprintf)(/* translators: %d: branch post ID. */ /* translators: %d: branch post ID. */
+(0,o.__)("Branch #%d","wp-branches-for-post"),e.id)),p("span",{className:"wbfp-branch-card__id"},(0,o.sprintf)(/* translators: %d: branch post ID. */ /* translators: %d: branch post ID. */
+(0,o.__)("Branch #%d","wp-branches-for-post"),e.id))),p("span",{className:`wbfp-state wbfp-state--${e.conflict}`},_(e.conflict))),(e.creator||e.modified_human)&&p("div",{className:"wbfp-meta wbfp-branch-card__meta"},e.creator&&p("span",null,(0,o.sprintf)(/* translators: %s: branch creator display name. */ /* translators: %s: branch creator display name. */
+(0,o.__)("Created by %s","wp-branches-for-post"),e.creator)),e.modified_human&&p("span",null,e.modified_human)),p("div",{className:"wbfp-meta"},(0,o.sprintf)(/* translators: 1: number of branch changes, 2: number of conflicts. */ /* translators: 1: number of branch changes, 2: number of conflicts. */
+(0,o.__)("%1$d branch changes · %2$d conflicts","wp-branches-for-post"),e.branch_changes||0,e.conflicts||0))));B=p(i.Fragment,null,p("p",null,(0,o.__)("Edit safely in a separate draft. The published post stays unchanged until you merge the branch.","wp-branches-for-post")),e.dirty&&p(s.Notice,{status:"warning",isDismissible:!1},(0,o.__)("This original has unsaved editor changes. Save or discard them before creating a branch so the baseline matches the saved public post.","wp-branches-for-post")),c.can_create&&p(s.Button,{variant:"primary",onClick:P,disabled:d||e.dirty||e.saving},d?(0,o.__)("Creating…","wp-branches-for-post"):(0,o.__)("Create branch","wp-branches-for-post")),c.branches?.length>0&&p("div",{className:"wbfp-existing-branches"},p("strong",null,(0,o.__)("Existing branches","wp-branches-for-post"),` (${c.branches.length})`),p("ul",null,a)))}else if("branch"===c?.type){const a=c.conflict,r=c.review||{},t=["conflict","changed","unknown","missing"].includes(a),n=c.legacy||(r.branch_changes?.length||0)>0,l=m&&p("div",{className:"wbfp-review"},p("div",{className:"wbfp-review__title"},(0,o.__)("Merge review","wp-branches-for-post")),c.legacy&&p(s.Notice,{status:"warning",isDismissible:!1},(0,o.__)("This branch predates three-way snapshots, so detailed change comparison is unavailable. Force merge only after manually reviewing the original.","wp-branches-for-post")),p(b,{title:(0,o.__)("Branch changes","wp-branches-for-post"),paths:r.branch_changes}),p(b,{title:(0,o.__)("Original changes since branch creation","wp-branches-for-post"),paths:r.original_changes}),p(b,{title:(0,o.__)("Conflicts","wp-branches-for-post"),paths:r.conflicts,danger:!0}),p(b,{title:(0,o.__)("Original identity changes preserved by merge","wp-branches-for-post"),paths:r.informational_changes}),p(w,{values:c.review_values}),!c.legacy&&!r.branch_changes?.length&&p("p",{className:"wbfp-meta"},(0,o.__)("No mergeable branch changes are currently detected.","wp-branches-for-post")),p("div",{className:"wbfp-review__actions"},c.can_merge&&!t&&n&&p(s.Button,{variant:"primary",onClick:()=>D(!1),disabled:d||e.saving},d?(0,o.__)("Saving and merging…","wp-branches-for-post"):(0,o.__)("Save & merge into original","wp-branches-for-post")),c.can_merge&&t&&c.can_force_merge&&"missing"!==a&&p(s.Button,{variant:"secondary",isDestructive:!0,onClick:()=>N("force"),disabled:d||e.saving},d?(0,o.__)("Saving and merging…","wp-branches-for-post"):(0,o.__)("Force merge reviewed conflicts","wp-branches-for-post"))));let h=null;"rebase_available"===a?h=p(s.Notice,{status:"info",isDismissible:!1},(0,o.__)("The original has newer, non-conflicting changes. You can update this branch first, or review and merge while preserving those original changes.","wp-branches-for-post")):"informational"===a?h=p(s.Notice,{status:"info",isDismissible:!1},(0,o.__)("The original identity changed (for example status, slug, author, or parent). Those changes are preserved and are not overwritten by this branch.","wp-branches-for-post")):"conflict"===a?h=p(s.Notice,{status:"warning",isDismissible:!1},(0,o.__)("The branch and original changed the same data differently. Review the conflicts before deciding whether to force merge.","wp-branches-for-post")):"changed"===a||"unknown"===a?h=p(s.Notice,{status:"warning",isDismissible:!1},(0,o.__)("This older branch cannot provide a precise three-way comparison. Review the original manually before forcing a merge.","wp-branches-for-post")):"missing"===a&&(h=p(s.Notice,{status:"error",isDismissible:!1},(0,o.__)("The original post is unavailable. This branch cannot be merged.","wp-branches-for-post"))),B=p(i.Fragment,null,p("p",null,(0,o.__)("This draft is isolated from the public original.","wp-branches-for-post"),c.original_edit_url&&p(i.Fragment,null," ",p("a",{href:c.original_edit_url},(0,o.__)("Open original","wp-branches-for-post")))),c.creator&&p("p",{className:"wbfp-meta"},(0,o.sprintf)(/* translators: %s: branch creator display name. */ /* translators: %s: branch creator display name. */
+(0,o.__)("Created by %s","wp-branches-for-post"),c.creator)),p("div",{className:`wbfp-state wbfp-state--${a}`},_(a)),c.synced_pattern_count>0&&p(s.Notice,{status:"warning",isDismissible:!1},(0,o.sprintf)(/* translators: %d: number of synced patterns referenced by the branch. */ /* translators: %d: number of synced patterns referenced by the branch. */
+(0,o.__)("This branch references %d synced pattern(s). Editing a synced pattern is global in WordPress and is not isolated by the branch.","wp-branches-for-post"),c.synced_pattern_count)),h,p("div",{className:"wbfp-preview-links"},c.branch_preview_url&&p("a",{href:c.branch_preview_url,target:"_blank",rel:"noreferrer"},(0,o.__)("Preview branch","wp-branches-for-post")),c.original_preview_url&&p("a",{href:c.original_preview_url,target:"_blank",rel:"noreferrer"},(0,o.__)("View original","wp-branches-for-post"))),c.can_rebase&&p(s.Button,{variant:"secondary",onClick:S,disabled:d||e.saving},d?(0,o.__)("Updating…","wp-branches-for-post"):(0,o.__)("Update branch from original","wp-branches-for-post")),p("div",{className:"wbfp-actions"},p(s.Button,{variant:m?"secondary":"primary",onClick:C,disabled:d},m?(0,o.__)("Hide merge review","wp-branches-for-post"):(0,o.__)("Review changes","wp-branches-for-post")),c.can_discard&&p(s.Button,{variant:"tertiary",isDestructive:!0,onClick:()=>N("discard"),disabled:d},(0,o.__)("Discard branch","wp-branches-for-post"))),l)}const O=y&&p(s.Modal,{title:"force"===y?(0,o.__)("Force merge conflicts?","wp-branches-for-post"):(0,o.__)("Discard this branch?","wp-branches-for-post"),onRequestClose:()=>N(null)},p("p",null,"force"===y?(0,o.__)("Force merge will prefer branch values for the listed conflicts. Non-conflicting newer work on the original is still preserved.","wp-branches-for-post"):(0,o.__)("The branch will be moved to Trash. The public original will not be changed.","wp-branches-for-post")),p("div",{className:"wbfp-confirm-actions"},p(s.Button,{variant:"secondary",onClick:()=>N(null)},(0,o.__)("Cancel","wp-branches-for-post")),p(s.Button,{variant:"primary",isDestructive:!0,onClick:()=>{const a=y;N(null),"force"===a?D(!0):(async()=>{f(!0),u("");try{const a=await r()({path:`/wbfp/v1/branches/${e.postId}`,method:"DELETE"});window.location.assign(a.edit_url||"edit.php")}catch(e){u(l(e)),f(!1)}})()}},"force"===y?(0,o.__)("Force merge","wp-branches-for-post"):(0,o.__)("Move to Trash","wp-branches-for-post"))));return p(n.PluginDocumentSettingPanel,{name:"wbfp-branch",title:(0,o.__)("Post Branch","wp-branches-for-post")},g&&p(s.Notice,{status:"error",onRemove:()=>u("")},g),B,O)}})})();
\ No newline at end of file
diff --git a/includes/class-admin.php b/includes/class-admin.php
index 10e9b41..d2023a2 100644
--- a/includes/class-admin.php
+++ b/includes/class-admin.php
@@ -52,6 +52,9 @@ public function register_hooks(): void {
add_filter( 'post_row_actions', array( $this, 'row_actions' ), 10, 2 );
add_filter( 'page_row_actions', array( $this, 'row_actions' ), 10, 2 );
add_filter( 'display_post_states', array( $this, 'post_states' ), 10, 2 );
+ add_filter( 'views_edit-post', array( $this, 'branch_views' ) );
+ add_filter( 'views_edit-page', array( $this, 'branch_views' ) );
+ add_action( 'pre_get_posts', array( $this, 'filter_branch_view_query' ) );
add_action( 'post_submitbox_misc_actions', array( $this, 'classic_editor_actions' ) );
add_action( 'admin_bar_menu', array( $this, 'admin_bar' ), 100 );
add_action( 'admin_notices', array( $this, 'admin_notices' ) );
@@ -121,15 +124,155 @@ public function row_actions( array $actions, \WP_Post $post ): array {
*/
public function post_states( array $states, \WP_Post $post ): array {
if ( $this->branches->is_branch( $post->ID ) ) {
- $states['wbfp_branch'] = sprintf(
+ $original_id = $this->branches->get_original_id( $post->ID );
+ $label = sprintf(
/* translators: %d: original post ID. */
- esc_html__( 'Branch of #%d', 'wp-branches-for-post' ),
- $this->branches->get_original_id( $post->ID )
+ __( 'Branch of #%d', 'wp-branches-for-post' ),
+ $original_id
);
+ $can_read_original = $original_id > 0 && current_user_can( 'read_post', $original_id );
+ $original = $can_read_original ? get_post( $original_id ) : null;
+ $title = $original ? trim( wp_strip_all_tags( get_the_title( $original ) ) ) : '';
+ if ( '' !== $title ) {
+ $label .= ' — ' . $title;
+ }
+ $states['wbfp_branch'] = $label;
}
return $states;
}
+ /**
+ * Add a dedicated branch view to the Posts and Pages list tables.
+ *
+ * @param array $views Existing list-table views.
+ * @return array
+ */
+ public function branch_views( array $views ): array {
+ $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+ $post_type = $screen && ! empty( $screen->post_type ) ? (string) $screen->post_type : 'post';
+ if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
+ return $views;
+ }
+
+ $count = $this->count_visible_branches( $post_type );
+ if ( $count < 1 ) {
+ return $views;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list-table filter state.
+ $current = isset( $_GET['wbfp_view'] ) && 'branches' === sanitize_key( wp_unslash( $_GET['wbfp_view'] ) );
+
+ $url = add_query_arg(
+ array_filter(
+ array(
+ 'post_type' => 'post' === $post_type ? null : $post_type,
+ 'wbfp_view' => 'branches',
+ )
+ ),
+ admin_url( 'edit.php' )
+ );
+
+ $views['wbfp_branches'] = sprintf(
+ '%3$s (%4$d)',
+ esc_url( $url ),
+ $current ? ' class="current" aria-current="page"' : '',
+ esc_html__( 'Existing branches', 'wp-branches-for-post' ),
+ $count
+ );
+
+ return $views;
+ }
+
+ /**
+ * Restrict the dedicated branch list view to active branch posts.
+ *
+ * @param \WP_Query $query Admin list query.
+ * @return void
+ */
+ public function filter_branch_view_query( \WP_Query $query ): void {
+ if ( ! is_admin() || ! $query->is_main_query() ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list-table filter state.
+ $view = isset( $_GET['wbfp_view'] ) ? sanitize_key( wp_unslash( $_GET['wbfp_view'] ) ) : '';
+ if ( 'branches' !== $view ) {
+ return;
+ }
+
+ $post_type = $query->get( 'post_type' );
+ $post_type = empty( $post_type ) ? 'post' : $post_type;
+ if ( ! is_string( $post_type ) || ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
+ return;
+ }
+
+ $post_type_object = get_post_type_object( $post_type );
+ if ( ! $post_type_object || ! current_user_can( $post_type_object->cap->edit_posts ) ) {
+ return;
+ }
+
+ $query->set( 'post_status', array( 'draft', 'pending', 'private', 'future' ) );
+ // Keep the list query aligned with the count shown by branch_views().
+ if ( ! current_user_can( $post_type_object->cap->edit_others_posts ) ) {
+ $query->set( 'author', get_current_user_id() );
+ }
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Branch relationships are stored as post meta by design.
+ $query->set( 'meta_query', $this->branch_relationship_meta_query() );
+ }
+
+ /**
+ * Count branch posts visible to the current user for one list table.
+ *
+ * @param string $post_type Post type.
+ * @return int
+ */
+ private function count_visible_branches( string $post_type ): int {
+ $post_type_object = get_post_type_object( $post_type );
+ if ( ! $post_type_object || ! current_user_can( $post_type_object->cap->edit_posts ) ) {
+ return 0;
+ }
+
+ $args = array(
+ 'post_type' => $post_type,
+ 'post_status' => array( 'draft', 'pending', 'private', 'future' ),
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => false,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Branch relationships are stored as post meta by design.
+ 'meta_query' => $this->branch_relationship_meta_query(),
+ );
+
+ if ( ! current_user_can( $post_type_object->cap->edit_others_posts ) ) {
+ $args['author'] = get_current_user_id();
+ }
+
+ $query = new \WP_Query( $args );
+ return (int) $query->found_posts;
+ }
+
+ /**
+ * Build the relationship query used by the branch list view.
+ *
+ * @return array
+ */
+ private function branch_relationship_meta_query(): array {
+ return array(
+ 'relation' => 'OR',
+ array(
+ 'key' => Branch_Service::META_ORIGINAL_ID,
+ 'value' => 0,
+ 'compare' => '>',
+ 'type' => 'NUMERIC',
+ ),
+ array(
+ 'key' => '_original_post_id',
+ 'value' => 0,
+ 'compare' => '>',
+ 'type' => 'NUMERIC',
+ ),
+ );
+ }
+
/**
* Render branch controls in the Classic Editor publish box.
*
diff --git a/includes/class-rest-controller.php b/includes/class-rest-controller.php
index deb9b0f..5ffd007 100644
--- a/includes/class-rest-controller.php
+++ b/includes/class-rest-controller.php
@@ -360,6 +360,7 @@ private function build_status( int $post_id ): array {
'title' => get_the_title( $branch ),
'edit_url' => get_edit_post_link( $branch->ID, 'raw' ),
'modified' => $branch->post_modified_gmt,
+ 'modified_human' => $this->modified_display_label( $branch ),
'creator' => $creator ? $creator->display_name : '',
'conflict' => $analysis['state'] ?? Branch_Service::CONFLICT_CHANGED,
'branch_changes' => count( $review['branch_changes'] ?? array() ),
@@ -408,8 +409,9 @@ private function review_token( int $branch_id ): string {
/**
* Return compact Base/Original/Branch values for human review.
*
- * Only core editorial text fields are returned. Arbitrary post meta values
- * are intentionally not exposed through this convenience payload.
+ * Core editorial fields, taxonomy term names, and featured-image identity
+ * are exposed to the authorized editor. Arbitrary post-meta values remain
+ * intentionally hidden even when their keys are listed as changed.
*
* @param array $analysis Branch analysis.
* @return array>
@@ -420,32 +422,148 @@ private function review_values( array $analysis ): array {
}
$review = isset( $analysis['analysis'] ) && is_array( $analysis['analysis'] ) ? $analysis['analysis'] : array();
- $paths = array_unique(
- array_merge(
- $review['branch_changes'] ?? array(),
- $review['original_changes'] ?? array(),
- $review['conflicts'] ?? array()
+ $paths = array_values(
+ array_unique(
+ array_merge(
+ $review['branch_changes'] ?? array(),
+ $review['original_changes'] ?? array(),
+ $review['conflicts'] ?? array()
+ )
)
);
- $fields = array( 'post_title', 'post_excerpt', 'post_content' );
$result = array();
- foreach ( $fields as $field ) {
- $path = 'post.' . $field;
- if ( ! in_array( $path, $paths, true ) ) {
+ foreach ( $paths as $path ) {
+ if ( str_starts_with( $path, 'post.' ) ) {
+ $field = substr( $path, 5 );
+ if ( ! in_array( $field, array( 'post_title', 'post_excerpt', 'post_content', 'menu_order', 'comment_status', 'ping_status', 'post_password' ), true ) ) {
+ continue;
+ }
+
+ $result[ $path ] = array(
+ 'base' => $this->review_post_value( $field, $analysis['base']['merge']['post'][ $field ] ?? '' ),
+ 'original' => $this->review_post_value( $field, $analysis['original']['merge']['post'][ $field ] ?? '' ),
+ 'branch' => $this->review_post_value( $field, $analysis['branch']['merge']['post'][ $field ] ?? '' ),
+ );
continue;
}
- $result[ $field ] = array(
- 'base' => $this->compact_review_text( $analysis['base']['merge']['post'][ $field ] ?? '' ),
- 'original' => $this->compact_review_text( $analysis['original']['merge']['post'][ $field ] ?? '' ),
- 'branch' => $this->compact_review_text( $analysis['branch']['merge']['post'][ $field ] ?? '' ),
- );
+ if ( str_starts_with( $path, 'taxonomies.' ) ) {
+ $taxonomy = substr( $path, 11 );
+ $object = get_taxonomy( $taxonomy );
+
+ $result[ $path ] = array(
+ 'label' => $object && ! empty( $object->labels->name ) ? (string) $object->labels->name : $taxonomy,
+ 'base' => $this->review_taxonomy_value( $taxonomy, $analysis['base']['merge']['taxonomies'][ $taxonomy ] ?? array() ),
+ 'original' => $this->review_taxonomy_value( $taxonomy, $analysis['original']['merge']['taxonomies'][ $taxonomy ] ?? array() ),
+ 'branch' => $this->review_taxonomy_value( $taxonomy, $analysis['branch']['merge']['taxonomies'][ $taxonomy ] ?? array() ),
+ );
+ continue;
+ }
+
+ if ( 'meta._thumbnail_id' === $path ) {
+ $base = $this->review_featured_image_value( $analysis['base']['merge']['meta']['_thumbnail_id'] ?? array() );
+ $original = $this->review_featured_image_value( $analysis['original']['merge']['meta']['_thumbnail_id'] ?? array() );
+ $branch = $this->review_featured_image_value( $analysis['branch']['merge']['meta']['_thumbnail_id'] ?? array() );
+ $post_type = (string) ( $analysis['branch']['identity']['post_type'] ?? '' );
+ $type_obj = '' !== $post_type ? get_post_type_object( $post_type ) : null;
+ $fallback_obj = get_post_type_object( 'post' );
+ $image_label = $type_obj && ! empty( $type_obj->labels->featured_image )
+ ? (string) $type_obj->labels->featured_image
+ : (string) ( $fallback_obj->labels->featured_image ?? '' );
+
+ $result[ $path ] = array(
+ 'label' => $image_label,
+ 'base' => $base['label'],
+ 'original' => $original['label'],
+ 'branch' => $branch['label'],
+ 'base_preview_url' => $base['url'],
+ 'original_preview_url' => $original['url'],
+ 'branch_preview_url' => $branch['url'],
+ );
+ }
}
return $result;
}
+ /**
+ * Format one core post field for the compact review.
+ *
+ * Passwords are deliberately masked rather than returned through REST.
+ *
+ * @param string $field Core post field.
+ * @param mixed $value Raw snapshot value.
+ * @return string
+ */
+ private function review_post_value( string $field, $value ): string {
+ if ( 'post_password' === $field ) {
+ return '' === (string) $value ? '—' : '••••••';
+ }
+
+ if ( 'menu_order' === $field ) {
+ return (string) (int) $value;
+ }
+
+ if ( in_array( $field, array( 'comment_status', 'ping_status' ), true ) ) {
+ $status = (string) $value;
+ if ( in_array( $status, array( 'open', 'closed' ), true ) ) {
+ return get_translations_for_domain( 'default' )->translate( 'open' === $status ? 'Open' : 'Closed' );
+ }
+ }
+
+ $text = $this->compact_review_text( $value );
+ return '' === $text ? '—' : $text;
+ }
+
+ /**
+ * Convert taxonomy term IDs into readable localized term names.
+ *
+ * @param string $taxonomy Taxonomy slug.
+ * @param mixed $value Snapshot taxonomy value.
+ * @return string
+ */
+ private function review_taxonomy_value( string $taxonomy, $value ): string {
+ $ids = is_array( $value ) ? array_values( array_filter( array_map( 'absint', $value ) ) ) : array();
+ if ( empty( $ids ) ) {
+ return '—';
+ }
+
+ $names = array();
+ foreach ( $ids as $term_id ) {
+ $term = get_term( $term_id, $taxonomy );
+ $names[] = $term && ! is_wp_error( $term ) ? (string) $term->name : '#' . $term_id;
+ }
+
+ return implode( ', ', $names );
+ }
+
+ /**
+ * Return a safe featured-image label and optional thumbnail URL.
+ *
+ * @param mixed $value Snapshot meta value.
+ * @return array{label:string,url:string}
+ */
+ private function review_featured_image_value( $value ): array {
+ $raw = is_array( $value ) ? reset( $value ) : $value;
+ $id = absint( $raw );
+ if ( $id < 1 ) {
+ return array(
+ 'label' => '—',
+ 'url' => '',
+ );
+ }
+
+ $attachment = get_post( $id );
+ $title = $attachment ? trim( wp_strip_all_tags( get_the_title( $attachment ) ) ) : '';
+ $url = wp_get_attachment_image_url( $id, 'thumbnail' );
+
+ return array(
+ 'label' => '' !== $title ? sprintf( '%1$s (#%2$d)', $title, $id ) : '#' . $id,
+ 'url' => $url ? (string) $url : '',
+ );
+ }
+
/**
* Convert editorial text to a bounded plain-text review excerpt.
*
@@ -457,6 +575,17 @@ private function compact_review_text( $value ): string {
return wp_html_excerpt( $text, 1200, strlen( $text ) > 1200 ? '…' : '' );
}
+ /**
+ * Build a localized modified-time display for branch cards.
+ *
+ * @param \WP_Post $post Branch post.
+ * @return string
+ */
+ private function modified_display_label( \WP_Post $post ): string {
+ $format = trim( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
+ return (string) get_post_modified_time( $format, false, $post );
+ }
+
/**
* Shared positive-integer REST argument schema.
*
diff --git a/languages/wp-branches-for-post-ja.po b/languages/wp-branches-for-post-ja.po
index 690bab2..263865c 100644
--- a/languages/wp-branches-for-post-ja.po
+++ b/languages/wp-branches-for-post-ja.po
@@ -46,35 +46,39 @@ msgstr "Hao Kexin"
msgid "https://time2log.com/about/"
msgstr "https://time2log.com/about/"
-#: includes/class-admin.php:92 build/index.js:8 src/index.js:684
+#: includes/class-admin.php:95 build/index.js:9 src/index.js:705
msgid "Review changes"
msgstr "変更を確認"
-#: includes/class-admin.php:98
+#: includes/class-admin.php:101
msgid "Merge branch"
msgstr "ブランチをマージ"
-#: includes/class-admin.php:109 includes/class-admin.php:190 build/index.js:6
-#: src/index.js:408
+#: includes/class-admin.php:112 includes/class-admin.php:333 build/index.js:7
+#: src/index.js:428
msgid "Create branch"
msgstr "ブランチを作成"
#. translators: %d: original post ID.
-#: includes/class-admin.php:126
+#: includes/class-admin.php:130
#, php-format
msgid "Branch of #%d"
msgstr "#%d のブランチ"
-#: includes/class-admin.php:153 includes/class-admin.php:175
-#: includes/class-admin.php:188 build/index.js:8 src/index.js:763
+#: includes/class-admin.php:179 build/index.js:7 src/index.js:437
+msgid "Existing branches"
+msgstr "既存のブランチ"
+
+#: includes/class-admin.php:296 includes/class-admin.php:318
+#: includes/class-admin.php:331 build/index.js:9 src/index.js:784
msgid "Post Branch"
msgstr "投稿ブランチ"
-#: includes/class-admin.php:154 build/index.js:6 src/index.js:575
+#: includes/class-admin.php:297 build/index.js:7 src/index.js:596
msgid "The original post is unavailable. This branch cannot be merged."
msgstr "元の投稿を利用できないため、このブランチはマージできません。"
-#: includes/class-admin.php:166 includes/class-admin.php:265
+#: includes/class-admin.php:309 includes/class-admin.php:408
msgid ""
"The original changed after this branch was created. This action will force "
"the merge and prefer branch values where changes overlap. Continue only "
@@ -84,20 +88,20 @@ msgstr ""
"い、重複する変更ではブランチ側の値を優先します。両方の内容を確認してから続行"
"してください。"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Force merge after review"
msgstr "確認後に強制マージ"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Merge into original"
msgstr "元の投稿へマージ"
-#: includes/class-admin.php:223
+#: includes/class-admin.php:366
msgid "Create Branch"
msgstr "ブランチを作成"
#. translators: 1: original post ID, 2: edit URL for the original post.
-#: includes/class-admin.php:255
+#: includes/class-admin.php:398
#, php-format
msgid ""
"This is a working branch of post #%1$d. The public original stays unchanged "
@@ -106,7 +110,7 @@ msgstr ""
"これは投稿 #%1$d の作業用ブランチです。このブランチを明示的にマージするまで、"
"公開中の元の投稿は変更されません。元の投稿を開く。"
-#: includes/class-admin.php:267
+#: includes/class-admin.php:410
msgid "WP Branches For Post could not complete the requested operation."
msgstr "WP Branches For Post は要求された操作を完了できませんでした。"
@@ -305,43 +309,15 @@ msgstr "マージ後の投稿状態を確認できませんでした。"
msgid "WP Branches For Post could not complete the request."
msgstr "WP Branches For Post は要求を完了できませんでした。"
-#: build/index.js:1 src/index.js:62
-msgid "Ready"
-msgstr "マージ可能"
-
-#: build/index.js:1 src/index.js:63
-msgid "Ready · original identity changed"
-msgstr "マージ可能 · 元の投稿の識別情報に変更あり"
-
-#: build/index.js:1 src/index.js:67
-msgid "Original updated · safe to refresh branch"
-msgstr "元の投稿が更新済み · 安全にブランチを更新できます"
-
-#: build/index.js:1 src/index.js:71
-msgid "Needs conflict review"
-msgstr "競合の確認が必要"
-
-#: build/index.js:1 src/index.js:72
-msgid "Original changed"
-msgstr "元の投稿に変更あり"
-
-#: build/index.js:1 src/index.js:73
-msgid "Legacy branch"
-msgstr "旧形式のブランチ"
-
-#: build/index.js:1 src/index.js:74
-msgid "Original unavailable"
-msgstr "元の投稿を利用できません"
-
-#: build/index.js:1 build/index.js:3 src/index.js:24 src/index.js:107
+#: build/index.js:1 src/index.js:24
msgid "Title"
msgstr "タイトル"
-#: build/index.js:1 build/index.js:3 src/index.js:25 src/index.js:109
+#: build/index.js:1 src/index.js:25
msgid "Content"
msgstr "本文"
-#: build/index.js:1 build/index.js:3 src/index.js:26 src/index.js:108
+#: build/index.js:1 src/index.js:26
msgid "Excerpt"
msgstr "抜粋"
@@ -391,23 +367,51 @@ msgstr "メタデータ: %s"
msgid "Taxonomy: %s"
msgstr "タクソノミー: %s"
-#: build/index.js:3 src/index.js:118
+#: build/index.js:3 src/index.js:62
+msgid "Ready"
+msgstr "マージ可能"
+
+#: build/index.js:3 src/index.js:63
+msgid "Ready · original identity changed"
+msgstr "マージ可能 · 元の投稿の識別情報に変更あり"
+
+#: build/index.js:3 src/index.js:67
+msgid "Original updated · safe to refresh branch"
+msgstr "元の投稿が更新済み · 安全にブランチを更新できます"
+
+#: build/index.js:3 src/index.js:71
+msgid "Needs conflict review"
+msgstr "競合の確認が必要"
+
+#: build/index.js:3 src/index.js:72
+msgid "Original changed"
+msgstr "元の投稿に変更あり"
+
+#: build/index.js:3 src/index.js:73
+msgid "Legacy branch"
+msgstr "旧形式のブランチ"
+
+#: build/index.js:3 src/index.js:74
+msgid "Original unavailable"
+msgstr "元の投稿を利用できません"
+
+#: build/index.js:3 src/index.js:126
msgid "Three-way comparison"
msgstr "三方向比較"
-#: build/index.js:3 src/index.js:128
+#: build/index.js:3 src/index.js:134
msgid "Base"
msgstr "基準"
-#: build/index.js:3 src/index.js:137
+#: build/index.js:3 src/index.js:139
msgid "Original now"
msgstr "現在の元の投稿"
-#: build/index.js:3 src/index.js:147
+#: build/index.js:3 src/index.js:144
msgid "Branch now"
msgstr "現在のブランチ"
-#: build/index.js:3 src/index.js:209
+#: build/index.js:3 src/index.js:206
msgid ""
"Save or discard the current edits to the original before creating a branch. "
"This prevents unpublished editor changes from being mistaken for the branch "
@@ -416,7 +420,7 @@ msgstr ""
"ブランチを作成する前に、元の投稿で現在編集中の内容を保存するか破棄してくださ"
"い。未公開の編集内容がブランチの基準として扱われるのを防ぎます。"
-#: build/index.js:3 src/index.js:287
+#: build/index.js:3 src/index.js:284
msgid ""
"The review state changed after saving. Review the latest conflicts before "
"merging."
@@ -424,21 +428,21 @@ msgstr ""
"保存後に確認状態が変わりました。マージ前に最新の競合を確認してください。"
#. translators: %d: branch post ID.
-#: build/index.js:4 src/index.js:341
+#: build/index.js:4 build/index.js:5 src/index.js:341 src/index.js:350
msgid "Branch #%d"
msgstr "ブランチ #%d"
#. translators: %s: branch creator display name.
-#: build/index.js:5 build/index.js:7 src/index.js:359 src/index.js:610
+#: build/index.js:6 build/index.js:8 src/index.js:373 src/index.js:631
msgid "Created by %s"
msgstr "作成者:%s"
#. translators: 1: number of branch changes, 2: number of conflicts.
-#: build/index.js:6 src/index.js:368
+#: build/index.js:7 src/index.js:388
msgid "%1$d branch changes · %2$d conflicts"
msgstr "ブランチの変更 %1$d 件 · 競合 %2$d 件"
-#: build/index.js:6 src/index.js:384
+#: build/index.js:7 src/index.js:404
msgid ""
"Edit safely in a separate draft. The published post stays unchanged until "
"you merge the branch."
@@ -446,7 +450,7 @@ msgstr ""
"独立した下書きで安全に編集できます。ブランチをマージするまで、公開中の投稿は"
"変更されません。"
-#: build/index.js:6 src/index.js:393
+#: build/index.js:7 src/index.js:413
msgid ""
"This original has unsaved editor changes. Save or discard them before "
"creating a branch so the baseline matches the saved public post."
@@ -454,19 +458,15 @@ msgstr ""
"元の投稿に未保存の編集があります。基準を保存済みの公開投稿と一致させるため、"
"ブランチ作成前に保存または破棄してください。"
-#: build/index.js:6 src/index.js:407
+#: build/index.js:7 src/index.js:427
msgid "Creating…"
msgstr "作成中…"
-#: build/index.js:6 src/index.js:417
-msgid "Existing branches"
-msgstr "既存のブランチ"
-
-#: build/index.js:6 src/index.js:441
+#: build/index.js:7 src/index.js:462
msgid "Merge review"
msgstr "マージ確認"
-#: build/index.js:6 src/index.js:447
+#: build/index.js:7 src/index.js:468
msgid ""
"This branch predates three-way snapshots, so detailed change comparison is "
"unavailable. Force merge only after manually reviewing the original."
@@ -474,39 +474,39 @@ msgstr ""
"このブランチは三方向スナップショット導入前に作成されたため、詳細な変更比較を"
"利用できません。元の投稿を手動で確認した後にのみ強制マージしてください。"
-#: build/index.js:6 src/index.js:453
+#: build/index.js:7 src/index.js:474
msgid "Branch changes"
msgstr "ブランチの変更"
-#: build/index.js:6 src/index.js:457
+#: build/index.js:7 src/index.js:478
msgid "Original changes since branch creation"
msgstr "ブランチ作成後の元の投稿の変更"
-#: build/index.js:6 src/index.js:464
+#: build/index.js:7 src/index.js:485
msgid "Conflicts"
msgstr "競合"
-#: build/index.js:6 src/index.js:469
+#: build/index.js:7 src/index.js:490
msgid "Original identity changes preserved by merge"
msgstr "マージで保持される元の投稿の識別情報の変更"
-#: build/index.js:6 src/index.js:481
+#: build/index.js:7 src/index.js:502
msgid "No mergeable branch changes are currently detected."
msgstr "現在、マージ可能なブランチ変更は検出されていません。"
-#: build/index.js:6 src/index.js:500 src/index.js:522
+#: build/index.js:7 src/index.js:521 src/index.js:543
msgid "Saving and merging…"
msgstr "保存してマージ中…"
-#: build/index.js:6 src/index.js:504
+#: build/index.js:7 src/index.js:525
msgid "Save & merge into original"
msgstr "保存して元の投稿へマージ"
-#: build/index.js:6 src/index.js:526
+#: build/index.js:7 src/index.js:547
msgid "Force merge reviewed conflicts"
msgstr "確認済みの競合を強制マージ"
-#: build/index.js:6 src/index.js:539
+#: build/index.js:7 src/index.js:560
msgid ""
"The original has newer, non-conflicting changes. You can update this branch "
"first, or review and merge while preserving those original changes."
@@ -514,7 +514,7 @@ msgstr ""
"元の投稿に新しい非競合の変更があります。先に元の投稿からブランチを更新する"
"か、その変更を保持したまま確認してマージできます。"
-#: build/index.js:6 src/index.js:548
+#: build/index.js:7 src/index.js:569
msgid ""
"The original identity changed (for example status, slug, author, or parent). "
"Those changes are preserved and are not overwritten by this branch."
@@ -522,7 +522,7 @@ msgstr ""
"元の投稿の識別情報(ステータス、スラッグ、投稿者、親など)が変更されていま"
"す。これらは保持され、このブランチでは上書きされません。"
-#: build/index.js:6 src/index.js:557
+#: build/index.js:7 src/index.js:578
msgid ""
"The branch and original changed the same data differently. Review the "
"conflicts before deciding whether to force merge."
@@ -530,7 +530,7 @@ msgstr ""
"ブランチと元の投稿で同じデータが別々に変更されています。強制マージするか判断"
"する前に競合を確認してください。"
-#: build/index.js:6 src/index.js:566
+#: build/index.js:7 src/index.js:587
msgid ""
"This older branch cannot provide a precise three-way comparison. Review the "
"original manually before forcing a merge."
@@ -538,16 +538,16 @@ msgstr ""
"この旧形式のブランチでは正確な三方向比較を行えません。強制マージ前に元の投稿"
"を手動で確認してください。"
-#: build/index.js:6 src/index.js:588
+#: build/index.js:7 src/index.js:609
msgid "This draft is isolated from the public original."
msgstr "この下書きは公開中の元の投稿から独立しています。"
-#: build/index.js:6 src/index.js:600
+#: build/index.js:7 src/index.js:621
msgid "Open original"
msgstr "元の投稿を開く"
#. translators: %d: number of synced patterns referenced by the branch.
-#: build/index.js:8 src/index.js:625
+#: build/index.js:9 src/index.js:646
msgid ""
"This branch references %d synced pattern(s). Editing a synced pattern is "
"global in WordPress and is not isolated by the branch."
@@ -555,39 +555,39 @@ msgstr ""
"このブランチは同期パターンを %d 件参照しています。同期パターンの編集は "
"WordPress 全体に反映され、ブランチでは分離されません。"
-#: build/index.js:8 src/index.js:644
+#: build/index.js:9 src/index.js:665
msgid "Preview branch"
msgstr "ブランチをプレビュー"
-#: build/index.js:8 src/index.js:654
+#: build/index.js:9 src/index.js:675
msgid "View original"
msgstr "元の投稿を表示"
-#: build/index.js:8 src/index.js:666
+#: build/index.js:9 src/index.js:687
msgid "Updating…"
msgstr "更新中…"
-#: build/index.js:8 src/index.js:667
+#: build/index.js:9 src/index.js:688
msgid "Update branch from original"
msgstr "元の投稿からブランチを更新"
-#: build/index.js:8 src/index.js:683
+#: build/index.js:9 src/index.js:704
msgid "Hide merge review"
msgstr "マージ確認を閉じる"
-#: build/index.js:8 src/index.js:695
+#: build/index.js:9 src/index.js:716
msgid "Discard branch"
msgstr "ブランチを破棄"
-#: build/index.js:8 src/index.js:709
+#: build/index.js:9 src/index.js:730
msgid "Force merge conflicts?"
msgstr "競合を強制マージしますか?"
-#: build/index.js:8 src/index.js:710
+#: build/index.js:9 src/index.js:731
msgid "Discard this branch?"
msgstr "このブランチを破棄しますか?"
-#: build/index.js:8 src/index.js:717
+#: build/index.js:9 src/index.js:738
msgid ""
"Force merge will prefer branch values for the listed conflicts. Non-"
"conflicting newer work on the original is still preserved."
@@ -595,19 +595,19 @@ msgstr ""
"強制マージでは、一覧の競合に対してブランチ側の値を優先します。競合しない元の"
"投稿の新しい変更は引き続き保持されます。"
-#: build/index.js:8 src/index.js:721
+#: build/index.js:9 src/index.js:742
msgid ""
"The branch will be moved to Trash. The public original will not be changed."
msgstr "ブランチはゴミ箱へ移動されます。公開中の元の投稿は変更されません。"
-#: build/index.js:8 src/index.js:735
+#: build/index.js:9 src/index.js:756
msgid "Cancel"
msgstr "キャンセル"
-#: build/index.js:8 src/index.js:753
+#: build/index.js:9 src/index.js:774
msgid "Force merge"
msgstr "強制マージ"
-#: build/index.js:8 src/index.js:754
+#: build/index.js:9 src/index.js:775
msgid "Move to Trash"
msgstr "ゴミ箱へ移動"
diff --git a/languages/wp-branches-for-post-zh_CN.po b/languages/wp-branches-for-post-zh_CN.po
index d694052..af35ec4 100644
--- a/languages/wp-branches-for-post-zh_CN.po
+++ b/languages/wp-branches-for-post-zh_CN.po
@@ -46,35 +46,39 @@ msgstr "Hao Kexin"
msgid "https://time2log.com/about/"
msgstr "https://time2log.com/about/"
-#: includes/class-admin.php:92 build/index.js:8 src/index.js:684
+#: includes/class-admin.php:95 build/index.js:9 src/index.js:705
msgid "Review changes"
msgstr "检查更改"
-#: includes/class-admin.php:98
+#: includes/class-admin.php:101
msgid "Merge branch"
msgstr "合并分支"
-#: includes/class-admin.php:109 includes/class-admin.php:190 build/index.js:6
-#: src/index.js:408
+#: includes/class-admin.php:112 includes/class-admin.php:333 build/index.js:7
+#: src/index.js:428
msgid "Create branch"
msgstr "创建分支"
#. translators: %d: original post ID.
-#: includes/class-admin.php:126
+#: includes/class-admin.php:130
#, php-format
msgid "Branch of #%d"
msgstr "#%d 的分支"
-#: includes/class-admin.php:153 includes/class-admin.php:175
-#: includes/class-admin.php:188 build/index.js:8 src/index.js:763
+#: includes/class-admin.php:179 build/index.js:7 src/index.js:437
+msgid "Existing branches"
+msgstr "现有分支"
+
+#: includes/class-admin.php:296 includes/class-admin.php:318
+#: includes/class-admin.php:331 build/index.js:9 src/index.js:784
msgid "Post Branch"
msgstr "文章分支"
-#: includes/class-admin.php:154 build/index.js:6 src/index.js:575
+#: includes/class-admin.php:297 build/index.js:7 src/index.js:596
msgid "The original post is unavailable. This branch cannot be merged."
msgstr "原始文章不可用,无法合并此分支。"
-#: includes/class-admin.php:166 includes/class-admin.php:265
+#: includes/class-admin.php:309 includes/class-admin.php:408
msgid ""
"The original changed after this branch was created. This action will force "
"the merge and prefer branch values where changes overlap. Continue only "
@@ -83,20 +87,20 @@ msgstr ""
"创建此分支后原始文章已发生变化。此操作将强制合并,并在重叠更改处优先采用分支"
"值。请检查两个版本后再继续。"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Force merge after review"
msgstr "检查后强制合并"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Merge into original"
msgstr "合并到原始文章"
-#: includes/class-admin.php:223
+#: includes/class-admin.php:366
msgid "Create Branch"
msgstr "创建分支"
#. translators: 1: original post ID, 2: edit URL for the original post.
-#: includes/class-admin.php:255
+#: includes/class-admin.php:398
#, php-format
msgid ""
"This is a working branch of post #%1$d. The public original stays unchanged "
@@ -105,7 +109,7 @@ msgstr ""
"这是文章 #%1$d 的工作分支。在你明确合并此分支之前,公开的原始文章不会发生变"
"化。打开原始文章。"
-#: includes/class-admin.php:267
+#: includes/class-admin.php:410
msgid "WP Branches For Post could not complete the requested operation."
msgstr "WP Branches For Post 无法完成请求的操作。"
@@ -286,43 +290,15 @@ msgstr "无法验证合并后的文章状态。"
msgid "WP Branches For Post could not complete the request."
msgstr "WP Branches For Post 无法完成请求。"
-#: build/index.js:1 src/index.js:62
-msgid "Ready"
-msgstr "可合并"
-
-#: build/index.js:1 src/index.js:63
-msgid "Ready · original identity changed"
-msgstr "可合并 · 原始文章标识已更改"
-
-#: build/index.js:1 src/index.js:67
-msgid "Original updated · safe to refresh branch"
-msgstr "原始文章已更新 · 可安全刷新分支"
-
-#: build/index.js:1 src/index.js:71
-msgid "Needs conflict review"
-msgstr "需要检查冲突"
-
-#: build/index.js:1 src/index.js:72
-msgid "Original changed"
-msgstr "原始文章已更改"
-
-#: build/index.js:1 src/index.js:73
-msgid "Legacy branch"
-msgstr "旧版分支"
-
-#: build/index.js:1 src/index.js:74
-msgid "Original unavailable"
-msgstr "原始文章不可用"
-
-#: build/index.js:1 build/index.js:3 src/index.js:24 src/index.js:107
+#: build/index.js:1 src/index.js:24
msgid "Title"
msgstr "标题"
-#: build/index.js:1 build/index.js:3 src/index.js:25 src/index.js:109
+#: build/index.js:1 src/index.js:25
msgid "Content"
msgstr "内容"
-#: build/index.js:1 build/index.js:3 src/index.js:26 src/index.js:108
+#: build/index.js:1 src/index.js:26
msgid "Excerpt"
msgstr "摘要"
@@ -372,23 +348,51 @@ msgstr "元数据:%s"
msgid "Taxonomy: %s"
msgstr "分类法:%s"
-#: build/index.js:3 src/index.js:118
+#: build/index.js:3 src/index.js:62
+msgid "Ready"
+msgstr "可合并"
+
+#: build/index.js:3 src/index.js:63
+msgid "Ready · original identity changed"
+msgstr "可合并 · 原始文章标识已更改"
+
+#: build/index.js:3 src/index.js:67
+msgid "Original updated · safe to refresh branch"
+msgstr "原始文章已更新 · 可安全刷新分支"
+
+#: build/index.js:3 src/index.js:71
+msgid "Needs conflict review"
+msgstr "需要检查冲突"
+
+#: build/index.js:3 src/index.js:72
+msgid "Original changed"
+msgstr "原始文章已更改"
+
+#: build/index.js:3 src/index.js:73
+msgid "Legacy branch"
+msgstr "旧版分支"
+
+#: build/index.js:3 src/index.js:74
+msgid "Original unavailable"
+msgstr "原始文章不可用"
+
+#: build/index.js:3 src/index.js:126
msgid "Three-way comparison"
msgstr "三方比较"
-#: build/index.js:3 src/index.js:128
+#: build/index.js:3 src/index.js:134
msgid "Base"
msgstr "基准版本"
-#: build/index.js:3 src/index.js:137
+#: build/index.js:3 src/index.js:139
msgid "Original now"
msgstr "原始文章当前值"
-#: build/index.js:3 src/index.js:147
+#: build/index.js:3 src/index.js:144
msgid "Branch now"
msgstr "分支当前值"
-#: build/index.js:3 src/index.js:209
+#: build/index.js:3 src/index.js:206
msgid ""
"Save or discard the current edits to the original before creating a branch. "
"This prevents unpublished editor changes from being mistaken for the branch "
@@ -397,34 +401,34 @@ msgstr ""
"创建分支前,请先保存或放弃原始文章当前未保存的编辑,避免把未发布的编辑误认为"
"分支基准。"
-#: build/index.js:3 src/index.js:287
+#: build/index.js:3 src/index.js:284
msgid ""
"The review state changed after saving. Review the latest conflicts before "
"merging."
msgstr "保存后检查状态发生了变化。合并前请检查最新冲突。"
#. translators: %d: branch post ID.
-#: build/index.js:4 src/index.js:341
+#: build/index.js:4 build/index.js:5 src/index.js:341 src/index.js:350
msgid "Branch #%d"
msgstr "分支 #%d"
#. translators: %s: branch creator display name.
-#: build/index.js:5 build/index.js:7 src/index.js:359 src/index.js:610
+#: build/index.js:6 build/index.js:8 src/index.js:373 src/index.js:631
msgid "Created by %s"
msgstr "创建者:%s"
#. translators: 1: number of branch changes, 2: number of conflicts.
-#: build/index.js:6 src/index.js:368
+#: build/index.js:7 src/index.js:388
msgid "%1$d branch changes · %2$d conflicts"
msgstr "%1$d 项分支更改 · %2$d 项冲突"
-#: build/index.js:6 src/index.js:384
+#: build/index.js:7 src/index.js:404
msgid ""
"Edit safely in a separate draft. The published post stays unchanged until "
"you merge the branch."
msgstr "可在独立草稿中安全编辑;在合并分支之前,已发布的文章不会发生变化。"
-#: build/index.js:6 src/index.js:393
+#: build/index.js:7 src/index.js:413
msgid ""
"This original has unsaved editor changes. Save or discard them before "
"creating a branch so the baseline matches the saved public post."
@@ -432,19 +436,15 @@ msgstr ""
"原始文章存在未保存的编辑。创建分支前请保存或放弃这些更改,使基准与已保存的公"
"开文章一致。"
-#: build/index.js:6 src/index.js:407
+#: build/index.js:7 src/index.js:427
msgid "Creating…"
msgstr "正在创建…"
-#: build/index.js:6 src/index.js:417
-msgid "Existing branches"
-msgstr "现有分支"
-
-#: build/index.js:6 src/index.js:441
+#: build/index.js:7 src/index.js:462
msgid "Merge review"
msgstr "合并检查"
-#: build/index.js:6 src/index.js:447
+#: build/index.js:7 src/index.js:468
msgid ""
"This branch predates three-way snapshots, so detailed change comparison is "
"unavailable. Force merge only after manually reviewing the original."
@@ -452,39 +452,39 @@ msgstr ""
"此分支创建于三方快照功能之前,因此无法提供详细更改比较。请手动检查原始文章后"
"再强制合并。"
-#: build/index.js:6 src/index.js:453
+#: build/index.js:7 src/index.js:474
msgid "Branch changes"
msgstr "分支更改"
-#: build/index.js:6 src/index.js:457
+#: build/index.js:7 src/index.js:478
msgid "Original changes since branch creation"
msgstr "创建分支后原始文章的更改"
-#: build/index.js:6 src/index.js:464
+#: build/index.js:7 src/index.js:485
msgid "Conflicts"
msgstr "冲突"
-#: build/index.js:6 src/index.js:469
+#: build/index.js:7 src/index.js:490
msgid "Original identity changes preserved by merge"
msgstr "合并时保留的原始文章标识更改"
-#: build/index.js:6 src/index.js:481
+#: build/index.js:7 src/index.js:502
msgid "No mergeable branch changes are currently detected."
msgstr "当前未检测到可合并的分支更改。"
-#: build/index.js:6 src/index.js:500 src/index.js:522
+#: build/index.js:7 src/index.js:521 src/index.js:543
msgid "Saving and merging…"
msgstr "正在保存并合并…"
-#: build/index.js:6 src/index.js:504
+#: build/index.js:7 src/index.js:525
msgid "Save & merge into original"
msgstr "保存并合并到原始文章"
-#: build/index.js:6 src/index.js:526
+#: build/index.js:7 src/index.js:547
msgid "Force merge reviewed conflicts"
msgstr "强制合并已检查的冲突"
-#: build/index.js:6 src/index.js:539
+#: build/index.js:7 src/index.js:560
msgid ""
"The original has newer, non-conflicting changes. You can update this branch "
"first, or review and merge while preserving those original changes."
@@ -492,7 +492,7 @@ msgstr ""
"原始文章有较新的非冲突更改。你可以先用原始文章更新分支,也可以检查后直接合并"
"并保留这些原始更改。"
-#: build/index.js:6 src/index.js:548
+#: build/index.js:7 src/index.js:569
msgid ""
"The original identity changed (for example status, slug, author, or parent). "
"Those changes are preserved and are not overwritten by this branch."
@@ -500,29 +500,29 @@ msgstr ""
"原始文章的标识信息发生了变化(例如状态、别名、作者或父级)。这些更改会被保"
"留,不会被此分支覆盖。"
-#: build/index.js:6 src/index.js:557
+#: build/index.js:7 src/index.js:578
msgid ""
"The branch and original changed the same data differently. Review the "
"conflicts before deciding whether to force merge."
msgstr ""
"分支和原始文章对同一数据做了不同修改。请先检查冲突,再决定是否强制合并。"
-#: build/index.js:6 src/index.js:566
+#: build/index.js:7 src/index.js:587
msgid ""
"This older branch cannot provide a precise three-way comparison. Review the "
"original manually before forcing a merge."
msgstr "这个旧版分支无法提供精确的三方比较。强制合并前请手动检查原始文章。"
-#: build/index.js:6 src/index.js:588
+#: build/index.js:7 src/index.js:609
msgid "This draft is isolated from the public original."
msgstr "此草稿与公开的原始文章相互独立。"
-#: build/index.js:6 src/index.js:600
+#: build/index.js:7 src/index.js:621
msgid "Open original"
msgstr "打开原始文章"
#. translators: %d: number of synced patterns referenced by the branch.
-#: build/index.js:8 src/index.js:625
+#: build/index.js:9 src/index.js:646
msgid ""
"This branch references %d synced pattern(s). Editing a synced pattern is "
"global in WordPress and is not isolated by the branch."
@@ -530,58 +530,58 @@ msgstr ""
"此分支引用了 %d 个同步区块样板。编辑同步样板会全局影响 WordPress,并不会被分"
"支隔离。"
-#: build/index.js:8 src/index.js:644
+#: build/index.js:9 src/index.js:665
msgid "Preview branch"
msgstr "预览分支"
-#: build/index.js:8 src/index.js:654
+#: build/index.js:9 src/index.js:675
msgid "View original"
msgstr "查看原始文章"
-#: build/index.js:8 src/index.js:666
+#: build/index.js:9 src/index.js:687
msgid "Updating…"
msgstr "正在更新…"
-#: build/index.js:8 src/index.js:667
+#: build/index.js:9 src/index.js:688
msgid "Update branch from original"
msgstr "从原始文章更新分支"
-#: build/index.js:8 src/index.js:683
+#: build/index.js:9 src/index.js:704
msgid "Hide merge review"
msgstr "隐藏合并检查"
-#: build/index.js:8 src/index.js:695
+#: build/index.js:9 src/index.js:716
msgid "Discard branch"
msgstr "放弃分支"
-#: build/index.js:8 src/index.js:709
+#: build/index.js:9 src/index.js:730
msgid "Force merge conflicts?"
msgstr "强制合并冲突吗?"
-#: build/index.js:8 src/index.js:710
+#: build/index.js:9 src/index.js:731
msgid "Discard this branch?"
msgstr "放弃这个分支吗?"
-#: build/index.js:8 src/index.js:717
+#: build/index.js:9 src/index.js:738
msgid ""
"Force merge will prefer branch values for the listed conflicts. Non-"
"conflicting newer work on the original is still preserved."
msgstr ""
"强制合并会对列出的冲突优先采用分支值;原始文章中不冲突的较新更改仍会保留。"
-#: build/index.js:8 src/index.js:721
+#: build/index.js:9 src/index.js:742
msgid ""
"The branch will be moved to Trash. The public original will not be changed."
msgstr "分支将被移到回收站,公开的原始文章不会发生变化。"
-#: build/index.js:8 src/index.js:735
+#: build/index.js:9 src/index.js:756
msgid "Cancel"
msgstr "取消"
-#: build/index.js:8 src/index.js:753
+#: build/index.js:9 src/index.js:774
msgid "Force merge"
msgstr "强制合并"
-#: build/index.js:8 src/index.js:754
+#: build/index.js:9 src/index.js:775
msgid "Move to Trash"
msgstr "移到回收站"
diff --git a/languages/wp-branches-for-post-zh_TW.po b/languages/wp-branches-for-post-zh_TW.po
index bb678ad..044c1ea 100644
--- a/languages/wp-branches-for-post-zh_TW.po
+++ b/languages/wp-branches-for-post-zh_TW.po
@@ -46,35 +46,39 @@ msgstr "Hao Kexin"
msgid "https://time2log.com/about/"
msgstr "https://time2log.com/about/"
-#: includes/class-admin.php:92 build/index.js:8 src/index.js:684
+#: includes/class-admin.php:95 build/index.js:9 src/index.js:705
msgid "Review changes"
msgstr "檢查變更"
-#: includes/class-admin.php:98
+#: includes/class-admin.php:101
msgid "Merge branch"
msgstr "合併分支"
-#: includes/class-admin.php:109 includes/class-admin.php:190 build/index.js:6
-#: src/index.js:408
+#: includes/class-admin.php:112 includes/class-admin.php:333 build/index.js:7
+#: src/index.js:428
msgid "Create branch"
msgstr "建立分支"
#. translators: %d: original post ID.
-#: includes/class-admin.php:126
+#: includes/class-admin.php:130
#, php-format
msgid "Branch of #%d"
msgstr "#%d 的分支"
-#: includes/class-admin.php:153 includes/class-admin.php:175
-#: includes/class-admin.php:188 build/index.js:8 src/index.js:763
+#: includes/class-admin.php:179 build/index.js:7 src/index.js:437
+msgid "Existing branches"
+msgstr "現有分支"
+
+#: includes/class-admin.php:296 includes/class-admin.php:318
+#: includes/class-admin.php:331 build/index.js:9 src/index.js:784
msgid "Post Branch"
msgstr "文章分支"
-#: includes/class-admin.php:154 build/index.js:6 src/index.js:575
+#: includes/class-admin.php:297 build/index.js:7 src/index.js:596
msgid "The original post is unavailable. This branch cannot be merged."
msgstr "原始文章無法使用,無法合併此分支。"
-#: includes/class-admin.php:166 includes/class-admin.php:265
+#: includes/class-admin.php:309 includes/class-admin.php:408
msgid ""
"The original changed after this branch was created. This action will force "
"the merge and prefer branch values where changes overlap. Continue only "
@@ -83,20 +87,20 @@ msgstr ""
"建立此分支後原始文章已發生變更。此操作將強制合併,並在重疊變更處優先採用分支"
"值。請檢查兩個版本後再繼續。"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Force merge after review"
msgstr "檢查後強制合併"
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Merge into original"
msgstr "合併到原始文章"
-#: includes/class-admin.php:223
+#: includes/class-admin.php:366
msgid "Create Branch"
msgstr "建立分支"
#. translators: 1: original post ID, 2: edit URL for the original post.
-#: includes/class-admin.php:255
+#: includes/class-admin.php:398
#, php-format
msgid ""
"This is a working branch of post #%1$d. The public original stays unchanged "
@@ -105,7 +109,7 @@ msgstr ""
"這是文章 #%1$d 的工作分支。在你明確合併此分支之前,公開的原始文章不會發生變"
"更。開啟原始文章。"
-#: includes/class-admin.php:267
+#: includes/class-admin.php:410
msgid "WP Branches For Post could not complete the requested operation."
msgstr "WP Branches For Post 無法完成要求的操作。"
@@ -286,43 +290,15 @@ msgstr "無法驗證合併後的文章狀態。"
msgid "WP Branches For Post could not complete the request."
msgstr "WP Branches For Post 無法完成要求。"
-#: build/index.js:1 src/index.js:62
-msgid "Ready"
-msgstr "可合併"
-
-#: build/index.js:1 src/index.js:63
-msgid "Ready · original identity changed"
-msgstr "可合併 · 原始文章識別已變更"
-
-#: build/index.js:1 src/index.js:67
-msgid "Original updated · safe to refresh branch"
-msgstr "原始文章已更新 · 可安全重新整理分支"
-
-#: build/index.js:1 src/index.js:71
-msgid "Needs conflict review"
-msgstr "需要檢查衝突"
-
-#: build/index.js:1 src/index.js:72
-msgid "Original changed"
-msgstr "原始文章已變更"
-
-#: build/index.js:1 src/index.js:73
-msgid "Legacy branch"
-msgstr "舊版分支"
-
-#: build/index.js:1 src/index.js:74
-msgid "Original unavailable"
-msgstr "原始文章無法使用"
-
-#: build/index.js:1 build/index.js:3 src/index.js:24 src/index.js:107
+#: build/index.js:1 src/index.js:24
msgid "Title"
msgstr "標題"
-#: build/index.js:1 build/index.js:3 src/index.js:25 src/index.js:109
+#: build/index.js:1 src/index.js:25
msgid "Content"
msgstr "內容"
-#: build/index.js:1 build/index.js:3 src/index.js:26 src/index.js:108
+#: build/index.js:1 src/index.js:26
msgid "Excerpt"
msgstr "摘要"
@@ -372,23 +348,51 @@ msgstr "中繼資料:%s"
msgid "Taxonomy: %s"
msgstr "分類法:%s"
-#: build/index.js:3 src/index.js:118
+#: build/index.js:3 src/index.js:62
+msgid "Ready"
+msgstr "可合併"
+
+#: build/index.js:3 src/index.js:63
+msgid "Ready · original identity changed"
+msgstr "可合併 · 原始文章識別已變更"
+
+#: build/index.js:3 src/index.js:67
+msgid "Original updated · safe to refresh branch"
+msgstr "原始文章已更新 · 可安全重新整理分支"
+
+#: build/index.js:3 src/index.js:71
+msgid "Needs conflict review"
+msgstr "需要檢查衝突"
+
+#: build/index.js:3 src/index.js:72
+msgid "Original changed"
+msgstr "原始文章已變更"
+
+#: build/index.js:3 src/index.js:73
+msgid "Legacy branch"
+msgstr "舊版分支"
+
+#: build/index.js:3 src/index.js:74
+msgid "Original unavailable"
+msgstr "原始文章無法使用"
+
+#: build/index.js:3 src/index.js:126
msgid "Three-way comparison"
msgstr "三方比較"
-#: build/index.js:3 src/index.js:128
+#: build/index.js:3 src/index.js:134
msgid "Base"
msgstr "基準版本"
-#: build/index.js:3 src/index.js:137
+#: build/index.js:3 src/index.js:139
msgid "Original now"
msgstr "原始文章目前值"
-#: build/index.js:3 src/index.js:147
+#: build/index.js:3 src/index.js:144
msgid "Branch now"
msgstr "分支目前值"
-#: build/index.js:3 src/index.js:209
+#: build/index.js:3 src/index.js:206
msgid ""
"Save or discard the current edits to the original before creating a branch. "
"This prevents unpublished editor changes from being mistaken for the branch "
@@ -397,34 +401,34 @@ msgstr ""
"建立分支前,請先儲存或捨棄原始文章目前未儲存的編輯,避免把未發佈的編輯誤認為"
"分支基準。"
-#: build/index.js:3 src/index.js:287
+#: build/index.js:3 src/index.js:284
msgid ""
"The review state changed after saving. Review the latest conflicts before "
"merging."
msgstr "儲存後檢查狀態發生變更。合併前請檢查最新衝突。"
#. translators: %d: branch post ID.
-#: build/index.js:4 src/index.js:341
+#: build/index.js:4 build/index.js:5 src/index.js:341 src/index.js:350
msgid "Branch #%d"
msgstr "分支 #%d"
#. translators: %s: branch creator display name.
-#: build/index.js:5 build/index.js:7 src/index.js:359 src/index.js:610
+#: build/index.js:6 build/index.js:8 src/index.js:373 src/index.js:631
msgid "Created by %s"
msgstr "建立者:%s"
#. translators: 1: number of branch changes, 2: number of conflicts.
-#: build/index.js:6 src/index.js:368
+#: build/index.js:7 src/index.js:388
msgid "%1$d branch changes · %2$d conflicts"
msgstr "%1$d 項分支變更 · %2$d 項衝突"
-#: build/index.js:6 src/index.js:384
+#: build/index.js:7 src/index.js:404
msgid ""
"Edit safely in a separate draft. The published post stays unchanged until "
"you merge the branch."
msgstr "可在獨立草稿中安全編輯;在合併分支之前,已發佈的文章不會發生變更。"
-#: build/index.js:6 src/index.js:393
+#: build/index.js:7 src/index.js:413
msgid ""
"This original has unsaved editor changes. Save or discard them before "
"creating a branch so the baseline matches the saved public post."
@@ -432,19 +436,15 @@ msgstr ""
"原始文章存在未儲存的編輯。建立分支前請儲存或捨棄這些變更,使基準與已儲存的公"
"開文章一致。"
-#: build/index.js:6 src/index.js:407
+#: build/index.js:7 src/index.js:427
msgid "Creating…"
msgstr "正在建立…"
-#: build/index.js:6 src/index.js:417
-msgid "Existing branches"
-msgstr "現有分支"
-
-#: build/index.js:6 src/index.js:441
+#: build/index.js:7 src/index.js:462
msgid "Merge review"
msgstr "合併檢查"
-#: build/index.js:6 src/index.js:447
+#: build/index.js:7 src/index.js:468
msgid ""
"This branch predates three-way snapshots, so detailed change comparison is "
"unavailable. Force merge only after manually reviewing the original."
@@ -452,39 +452,39 @@ msgstr ""
"此分支建立於三方快照功能之前,因此無法提供詳細變更比較。請手動檢查原始文章後"
"再強制合併。"
-#: build/index.js:6 src/index.js:453
+#: build/index.js:7 src/index.js:474
msgid "Branch changes"
msgstr "分支變更"
-#: build/index.js:6 src/index.js:457
+#: build/index.js:7 src/index.js:478
msgid "Original changes since branch creation"
msgstr "建立分支後原始文章的變更"
-#: build/index.js:6 src/index.js:464
+#: build/index.js:7 src/index.js:485
msgid "Conflicts"
msgstr "衝突"
-#: build/index.js:6 src/index.js:469
+#: build/index.js:7 src/index.js:490
msgid "Original identity changes preserved by merge"
msgstr "合併時保留的原始文章識別變更"
-#: build/index.js:6 src/index.js:481
+#: build/index.js:7 src/index.js:502
msgid "No mergeable branch changes are currently detected."
msgstr "目前未偵測到可合併的分支變更。"
-#: build/index.js:6 src/index.js:500 src/index.js:522
+#: build/index.js:7 src/index.js:521 src/index.js:543
msgid "Saving and merging…"
msgstr "正在儲存並合併…"
-#: build/index.js:6 src/index.js:504
+#: build/index.js:7 src/index.js:525
msgid "Save & merge into original"
msgstr "儲存並合併到原始文章"
-#: build/index.js:6 src/index.js:526
+#: build/index.js:7 src/index.js:547
msgid "Force merge reviewed conflicts"
msgstr "強制合併已檢查的衝突"
-#: build/index.js:6 src/index.js:539
+#: build/index.js:7 src/index.js:560
msgid ""
"The original has newer, non-conflicting changes. You can update this branch "
"first, or review and merge while preserving those original changes."
@@ -492,7 +492,7 @@ msgstr ""
"原始文章有較新的非衝突變更。你可以先用原始文章更新分支,也可以檢查後直接合併"
"並保留這些原始變更。"
-#: build/index.js:6 src/index.js:548
+#: build/index.js:7 src/index.js:569
msgid ""
"The original identity changed (for example status, slug, author, or parent). "
"Those changes are preserved and are not overwritten by this branch."
@@ -500,29 +500,29 @@ msgstr ""
"原始文章的識別資訊發生變更(例如狀態、代稱、作者或上層)。這些變更會保留,不"
"會被此分支覆寫。"
-#: build/index.js:6 src/index.js:557
+#: build/index.js:7 src/index.js:578
msgid ""
"The branch and original changed the same data differently. Review the "
"conflicts before deciding whether to force merge."
msgstr ""
"分支和原始文章對同一資料做了不同修改。請先檢查衝突,再決定是否強制合併。"
-#: build/index.js:6 src/index.js:566
+#: build/index.js:7 src/index.js:587
msgid ""
"This older branch cannot provide a precise three-way comparison. Review the "
"original manually before forcing a merge."
msgstr "這個舊版分支無法提供精確的三方比較。強制合併前請手動檢查原始文章。"
-#: build/index.js:6 src/index.js:588
+#: build/index.js:7 src/index.js:609
msgid "This draft is isolated from the public original."
msgstr "此草稿與公開的原始文章相互獨立。"
-#: build/index.js:6 src/index.js:600
+#: build/index.js:7 src/index.js:621
msgid "Open original"
msgstr "開啟原始文章"
#. translators: %d: number of synced patterns referenced by the branch.
-#: build/index.js:8 src/index.js:625
+#: build/index.js:9 src/index.js:646
msgid ""
"This branch references %d synced pattern(s). Editing a synced pattern is "
"global in WordPress and is not isolated by the branch."
@@ -530,58 +530,58 @@ msgstr ""
"此分支引用了 %d 個同步區塊版面配置。編輯同步版面配置會全域影響 WordPress,並"
"不會被分支隔離。"
-#: build/index.js:8 src/index.js:644
+#: build/index.js:9 src/index.js:665
msgid "Preview branch"
msgstr "預覽分支"
-#: build/index.js:8 src/index.js:654
+#: build/index.js:9 src/index.js:675
msgid "View original"
msgstr "檢視原始文章"
-#: build/index.js:8 src/index.js:666
+#: build/index.js:9 src/index.js:687
msgid "Updating…"
msgstr "正在更新…"
-#: build/index.js:8 src/index.js:667
+#: build/index.js:9 src/index.js:688
msgid "Update branch from original"
msgstr "從原始文章更新分支"
-#: build/index.js:8 src/index.js:683
+#: build/index.js:9 src/index.js:704
msgid "Hide merge review"
msgstr "隱藏合併檢查"
-#: build/index.js:8 src/index.js:695
+#: build/index.js:9 src/index.js:716
msgid "Discard branch"
msgstr "捨棄分支"
-#: build/index.js:8 src/index.js:709
+#: build/index.js:9 src/index.js:730
msgid "Force merge conflicts?"
msgstr "強制合併衝突嗎?"
-#: build/index.js:8 src/index.js:710
+#: build/index.js:9 src/index.js:731
msgid "Discard this branch?"
msgstr "捨棄這個分支嗎?"
-#: build/index.js:8 src/index.js:717
+#: build/index.js:9 src/index.js:738
msgid ""
"Force merge will prefer branch values for the listed conflicts. Non-"
"conflicting newer work on the original is still preserved."
msgstr ""
"強制合併會對列出的衝突優先採用分支值;原始文章中不衝突的較新變更仍會保留。"
-#: build/index.js:8 src/index.js:721
+#: build/index.js:9 src/index.js:742
msgid ""
"The branch will be moved to Trash. The public original will not be changed."
msgstr "分支將移至回收桶,公開的原始文章不會變更。"
-#: build/index.js:8 src/index.js:735
+#: build/index.js:9 src/index.js:756
msgid "Cancel"
msgstr "取消"
-#: build/index.js:8 src/index.js:753
+#: build/index.js:9 src/index.js:774
msgid "Force merge"
msgstr "強制合併"
-#: build/index.js:8 src/index.js:754
+#: build/index.js:9 src/index.js:775
msgid "Move to Trash"
msgstr "移至回收桶"
diff --git a/languages/wp-branches-for-post.pot b/languages/wp-branches-for-post.pot
index 2bc9538..5000a23 100644
--- a/languages/wp-branches-for-post.pot
+++ b/languages/wp-branches-for-post.pot
@@ -2,7 +2,7 @@
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
-"Project-Id-Version: WP Branches For Post 2.1.0\n"
+"Project-Id-Version: WP Branches For Post 2.1.1\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-branches-for-post\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -39,67 +39,73 @@ msgstr ""
msgid "https://time2log.com/about/"
msgstr ""
-#: includes/class-admin.php:92
-#: build/index.js:8
-#: src/index.js:684
+#: includes/class-admin.php:95
+#: build/index.js:9
+#: src/index.js:705
msgid "Review changes"
msgstr ""
-#: includes/class-admin.php:98
+#: includes/class-admin.php:101
msgid "Merge branch"
msgstr ""
-#: includes/class-admin.php:109
-#: includes/class-admin.php:190
-#: build/index.js:6
-#: src/index.js:408
+#: includes/class-admin.php:112
+#: includes/class-admin.php:333
+#: build/index.js:7
+#: src/index.js:428
msgid "Create branch"
msgstr ""
#. translators: %d: original post ID.
-#: includes/class-admin.php:126
+#: includes/class-admin.php:130
#, php-format
msgid "Branch of #%d"
msgstr ""
-#: includes/class-admin.php:153
-#: includes/class-admin.php:175
-#: includes/class-admin.php:188
-#: build/index.js:8
-#: src/index.js:763
+#: includes/class-admin.php:179
+#: build/index.js:7
+#: src/index.js:437
+msgid "Existing branches"
+msgstr ""
+
+#: includes/class-admin.php:296
+#: includes/class-admin.php:318
+#: includes/class-admin.php:331
+#: build/index.js:9
+#: src/index.js:784
msgid "Post Branch"
msgstr ""
-#: includes/class-admin.php:154
-#: build/index.js:6
-#: src/index.js:575
+#: includes/class-admin.php:297
+#: build/index.js:7
+#: src/index.js:596
msgid "The original post is unavailable. This branch cannot be merged."
msgstr ""
-#: includes/class-admin.php:166
-#: includes/class-admin.php:265
+#: includes/class-admin.php:309
+#: includes/class-admin.php:408
msgid "The original changed after this branch was created. This action will force the merge and prefer branch values where changes overlap. Continue only after reviewing both versions."
msgstr ""
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Force merge after review"
msgstr ""
-#: includes/class-admin.php:178
+#: includes/class-admin.php:321
msgid "Merge into original"
msgstr ""
-#: includes/class-admin.php:223
+#: includes/class-admin.php:366
msgid "Create Branch"
msgstr ""
#. translators: 1: original post ID, 2: edit URL for the original post.
-#: includes/class-admin.php:255
+#: includes/class-admin.php:398
#, php-format
msgid "This is a working branch of post #%1$d. The public original stays unchanged until you explicitly merge this branch. Open original."
msgstr ""
-#: includes/class-admin.php:267
+#: includes/class-admin.php:410
msgid "WP Branches For Post could not complete the requested operation."
msgstr ""
@@ -268,58 +274,17 @@ msgid "WP Branches For Post could not complete the request."
msgstr ""
#: build/index.js:1
-#: src/index.js:62
-msgid "Ready"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:63
-msgid "Ready · original identity changed"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:67
-msgid "Original updated · safe to refresh branch"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:71
-msgid "Needs conflict review"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:72
-msgid "Original changed"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:73
-msgid "Legacy branch"
-msgstr ""
-
-#: build/index.js:1
-#: src/index.js:74
-msgid "Original unavailable"
-msgstr ""
-
-#: build/index.js:1
-#: build/index.js:3
#: src/index.js:24
-#: src/index.js:107
msgid "Title"
msgstr ""
#: build/index.js:1
-#: build/index.js:3
#: src/index.js:25
-#: src/index.js:109
msgid "Content"
msgstr ""
#: build/index.js:1
-#: build/index.js:3
#: src/index.js:26
-#: src/index.js:108
msgid "Excerpt"
msgstr ""
@@ -383,227 +348,259 @@ msgid "Taxonomy: %s"
msgstr ""
#: build/index.js:3
-#: src/index.js:118
+#: src/index.js:62
+msgid "Ready"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:63
+msgid "Ready · original identity changed"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:67
+msgid "Original updated · safe to refresh branch"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:71
+msgid "Needs conflict review"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:72
+msgid "Original changed"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:73
+msgid "Legacy branch"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:74
+msgid "Original unavailable"
+msgstr ""
+
+#: build/index.js:3
+#: src/index.js:126
msgid "Three-way comparison"
msgstr ""
#: build/index.js:3
-#: src/index.js:128
+#: src/index.js:134
msgid "Base"
msgstr ""
#: build/index.js:3
-#: src/index.js:137
+#: src/index.js:139
msgid "Original now"
msgstr ""
#: build/index.js:3
-#: src/index.js:147
+#: src/index.js:144
msgid "Branch now"
msgstr ""
#: build/index.js:3
-#: src/index.js:209
+#: src/index.js:206
msgid "Save or discard the current edits to the original before creating a branch. This prevents unpublished editor changes from being mistaken for the branch baseline."
msgstr ""
#: build/index.js:3
-#: src/index.js:287
+#: src/index.js:284
msgid "The review state changed after saving. Review the latest conflicts before merging."
msgstr ""
#. translators: %d: branch post ID.
#: build/index.js:4
+#: build/index.js:5
#: src/index.js:341
+#: src/index.js:350
#, js-format
msgid "Branch #%d"
msgstr ""
#. translators: %s: branch creator display name.
-#: build/index.js:5
-#: build/index.js:7
-#: src/index.js:359
-#: src/index.js:610
+#: build/index.js:6
+#: build/index.js:8
+#: src/index.js:373
+#: src/index.js:631
#, js-format
msgid "Created by %s"
msgstr ""
#. translators: 1: number of branch changes, 2: number of conflicts.
-#: build/index.js:6
-#: src/index.js:368
+#: build/index.js:7
+#: src/index.js:388
#, js-format
msgid "%1$d branch changes · %2$d conflicts"
msgstr ""
-#: build/index.js:6
-#: src/index.js:384
+#: build/index.js:7
+#: src/index.js:404
msgid "Edit safely in a separate draft. The published post stays unchanged until you merge the branch."
msgstr ""
-#: build/index.js:6
-#: src/index.js:393
+#: build/index.js:7
+#: src/index.js:413
msgid "This original has unsaved editor changes. Save or discard them before creating a branch so the baseline matches the saved public post."
msgstr ""
-#: build/index.js:6
-#: src/index.js:407
+#: build/index.js:7
+#: src/index.js:427
msgid "Creating…"
msgstr ""
-#: build/index.js:6
-#: src/index.js:417
-msgid "Existing branches"
-msgstr ""
-
-#: build/index.js:6
-#: src/index.js:441
+#: build/index.js:7
+#: src/index.js:462
msgid "Merge review"
msgstr ""
-#: build/index.js:6
-#: src/index.js:447
+#: build/index.js:7
+#: src/index.js:468
msgid "This branch predates three-way snapshots, so detailed change comparison is unavailable. Force merge only after manually reviewing the original."
msgstr ""
-#: build/index.js:6
-#: src/index.js:453
+#: build/index.js:7
+#: src/index.js:474
msgid "Branch changes"
msgstr ""
-#: build/index.js:6
-#: src/index.js:457
+#: build/index.js:7
+#: src/index.js:478
msgid "Original changes since branch creation"
msgstr ""
-#: build/index.js:6
-#: src/index.js:464
+#: build/index.js:7
+#: src/index.js:485
msgid "Conflicts"
msgstr ""
-#: build/index.js:6
-#: src/index.js:469
+#: build/index.js:7
+#: src/index.js:490
msgid "Original identity changes preserved by merge"
msgstr ""
-#: build/index.js:6
-#: src/index.js:481
+#: build/index.js:7
+#: src/index.js:502
msgid "No mergeable branch changes are currently detected."
msgstr ""
-#: build/index.js:6
-#: src/index.js:500
-#: src/index.js:522
+#: build/index.js:7
+#: src/index.js:521
+#: src/index.js:543
msgid "Saving and merging…"
msgstr ""
-#: build/index.js:6
-#: src/index.js:504
+#: build/index.js:7
+#: src/index.js:525
msgid "Save & merge into original"
msgstr ""
-#: build/index.js:6
-#: src/index.js:526
+#: build/index.js:7
+#: src/index.js:547
msgid "Force merge reviewed conflicts"
msgstr ""
-#: build/index.js:6
-#: src/index.js:539
+#: build/index.js:7
+#: src/index.js:560
msgid "The original has newer, non-conflicting changes. You can update this branch first, or review and merge while preserving those original changes."
msgstr ""
-#: build/index.js:6
-#: src/index.js:548
+#: build/index.js:7
+#: src/index.js:569
msgid "The original identity changed (for example status, slug, author, or parent). Those changes are preserved and are not overwritten by this branch."
msgstr ""
-#: build/index.js:6
-#: src/index.js:557
+#: build/index.js:7
+#: src/index.js:578
msgid "The branch and original changed the same data differently. Review the conflicts before deciding whether to force merge."
msgstr ""
-#: build/index.js:6
-#: src/index.js:566
+#: build/index.js:7
+#: src/index.js:587
msgid "This older branch cannot provide a precise three-way comparison. Review the original manually before forcing a merge."
msgstr ""
-#: build/index.js:6
-#: src/index.js:588
+#: build/index.js:7
+#: src/index.js:609
msgid "This draft is isolated from the public original."
msgstr ""
-#: build/index.js:6
-#: src/index.js:600
+#: build/index.js:7
+#: src/index.js:621
msgid "Open original"
msgstr ""
#. translators: %d: number of synced patterns referenced by the branch.
-#: build/index.js:8
-#: src/index.js:625
+#: build/index.js:9
+#: src/index.js:646
#, js-format
msgid "This branch references %d synced pattern(s). Editing a synced pattern is global in WordPress and is not isolated by the branch."
msgstr ""
-#: build/index.js:8
-#: src/index.js:644
+#: build/index.js:9
+#: src/index.js:665
msgid "Preview branch"
msgstr ""
-#: build/index.js:8
-#: src/index.js:654
+#: build/index.js:9
+#: src/index.js:675
msgid "View original"
msgstr ""
-#: build/index.js:8
-#: src/index.js:666
+#: build/index.js:9
+#: src/index.js:687
msgid "Updating…"
msgstr ""
-#: build/index.js:8
-#: src/index.js:667
+#: build/index.js:9
+#: src/index.js:688
msgid "Update branch from original"
msgstr ""
-#: build/index.js:8
-#: src/index.js:683
+#: build/index.js:9
+#: src/index.js:704
msgid "Hide merge review"
msgstr ""
-#: build/index.js:8
-#: src/index.js:695
+#: build/index.js:9
+#: src/index.js:716
msgid "Discard branch"
msgstr ""
-#: build/index.js:8
-#: src/index.js:709
+#: build/index.js:9
+#: src/index.js:730
msgid "Force merge conflicts?"
msgstr ""
-#: build/index.js:8
-#: src/index.js:710
+#: build/index.js:9
+#: src/index.js:731
msgid "Discard this branch?"
msgstr ""
-#: build/index.js:8
-#: src/index.js:717
+#: build/index.js:9
+#: src/index.js:738
msgid "Force merge will prefer branch values for the listed conflicts. Non-conflicting newer work on the original is still preserved."
msgstr ""
-#: build/index.js:8
-#: src/index.js:721
+#: build/index.js:9
+#: src/index.js:742
msgid "The branch will be moved to Trash. The public original will not be changed."
msgstr ""
-#: build/index.js:8
-#: src/index.js:735
+#: build/index.js:9
+#: src/index.js:756
msgid "Cancel"
msgstr ""
-#: build/index.js:8
-#: src/index.js:753
+#: build/index.js:9
+#: src/index.js:774
msgid "Force merge"
msgstr ""
-#: build/index.js:8
-#: src/index.js:754
+#: build/index.js:9
+#: src/index.js:775
msgid "Move to Trash"
msgstr ""
diff --git a/package-lock.json b/package-lock.json
index fc7fec1..6838439 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "wp-branches-for-post",
- "version": "2.1.0",
+ "version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wp-branches-for-post",
- "version": "2.1.0",
+ "version": "2.1.1",
"dependencies": {
"@wordpress/api-fetch": "^7.55.0",
"@wordpress/components": "^40.1.0",
diff --git a/package.json b/package.json
index ae40543..70a2286 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wp-branches-for-post",
- "version": "2.1.0",
+ "version": "2.1.1",
"private": true,
"description": "Modern block-editor tooling for WP Branches For Post.",
"scripts": {
diff --git a/post-branch.php b/post-branch.php
index 5530f34..913eb04 100644
--- a/post-branch.php
+++ b/post-branch.php
@@ -3,7 +3,7 @@
* Plugin Name: WP Branches For Post
* Plugin URI: https://github.com/hsxk/WP-Branches-For-Post/
* Description: Create safe working branches for published WordPress content and merge them back without changing the public post while editing.
- * Version: 2.1.0
+ * Version: 2.1.1
* Requires at least: 6.6
* Requires PHP: 8.2
* Author: Hao Kexin
@@ -20,7 +20,7 @@
exit;
}
-define( 'WBFP_VERSION', '2.1.0' );
+define( 'WBFP_VERSION', '2.1.1' );
define( 'WBFP_FILE', __FILE__ );
define( 'WBFP_DIR', plugin_dir_path( __FILE__ ) );
define( 'WBFP_URL', plugin_dir_url( __FILE__ ) );
diff --git a/src/index.js b/src/index.js
index 673406b..f03940e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -103,11 +103,19 @@ function ReviewValues( { values } ) {
return null;
}
- const labels = {
- post_title: __( 'Title', 'wp-branches-for-post' ),
- post_excerpt: __( 'Excerpt', 'wp-branches-for-post' ),
- post_content: __( 'Content', 'wp-branches-for-post' ),
- };
+ const version = ( label, text, previewUrl ) =>
+ el(
+ 'div',
+ { className: 'wbfp-compare__version' },
+ el( 'span', null, label ),
+ previewUrl &&
+ el( 'img', {
+ className: 'wbfp-compare__thumbnail',
+ src: previewUrl,
+ alt: '',
+ } ),
+ el( 'pre', null, text || '—' )
+ );
return el(
'div',
@@ -117,36 +125,25 @@ function ReviewValues( { values } ) {
null,
__( 'Three-way comparison', 'wp-branches-for-post' )
),
- ...Object.entries( values ).map( ( [ field, value ] ) =>
+ ...Object.entries( values ).map( ( [ path, value ] ) =>
el(
'details',
- { key: field, className: 'wbfp-compare__field' },
- el( 'summary', null, labels[ field ] || field ),
- el(
- 'div',
- { className: 'wbfp-compare__version' },
- el( 'span', null, __( 'Base', 'wp-branches-for-post' ) ),
- el( 'pre', null, value.base || '—' )
+ { key: path, className: 'wbfp-compare__field' },
+ el( 'summary', null, value.label || pathLabel( path ) ),
+ version(
+ __( 'Base', 'wp-branches-for-post' ),
+ value.base,
+ value.base_preview_url
),
- el(
- 'div',
- { className: 'wbfp-compare__version' },
- el(
- 'span',
- null,
- __( 'Original now', 'wp-branches-for-post' )
- ),
- el( 'pre', null, value.original || '—' )
+ version(
+ __( 'Original now', 'wp-branches-for-post' ),
+ value.original,
+ value.original_preview_url
),
- el(
- 'div',
- { className: 'wbfp-compare__version' },
- el(
- 'span',
- null,
- __( 'Branch now', 'wp-branches-for-post' )
- ),
- el( 'pre', null, value.branch || '—' )
+ version(
+ __( 'Branch now', 'wp-branches-for-post' ),
+ value.branch,
+ value.branch_preview_url
)
)
)
@@ -333,14 +330,27 @@ function BranchPanel() {
'div',
{ className: 'wbfp-branch-card__head' },
el(
- 'a',
- { href: branch.edit_url },
- branch.title ||
+ 'div',
+ { className: 'wbfp-branch-card__identity' },
+ el(
+ 'a',
+ { href: branch.edit_url },
+ branch.title ||
+ sprintf(
+ /* translators: %d: branch post ID. */
+ __( 'Branch #%d', 'wp-branches-for-post' ),
+ branch.id
+ )
+ ),
+ el(
+ 'span',
+ { className: 'wbfp-branch-card__id' },
sprintf(
/* translators: %d: branch post ID. */
__( 'Branch #%d', 'wp-branches-for-post' ),
branch.id
)
+ )
),
el(
'span',
@@ -350,15 +360,25 @@ function BranchPanel() {
stateLabel( branch.conflict )
)
),
- branch.creator &&
+ ( branch.creator || branch.modified_human ) &&
el(
'div',
- { className: 'wbfp-meta' },
- sprintf(
- /* translators: %s: branch creator display name. */
- __( 'Created by %s', 'wp-branches-for-post' ),
- branch.creator
- )
+ { className: 'wbfp-meta wbfp-branch-card__meta' },
+ branch.creator &&
+ el(
+ 'span',
+ null,
+ sprintf(
+ /* translators: %s: branch creator display name. */
+ __(
+ 'Created by %s',
+ 'wp-branches-for-post'
+ ),
+ branch.creator
+ )
+ ),
+ branch.modified_human &&
+ el( 'span', null, branch.modified_human )
),
el(
'div',
@@ -414,7 +434,8 @@ function BranchPanel() {
el(
'strong',
null,
- __( 'Existing branches', 'wp-branches-for-post' )
+ __( 'Existing branches', 'wp-branches-for-post' ),
+ ` (${ status.branches.length })`
),
el( 'ul', null, branchCards )
)
diff --git a/tests/browser-2.1.js b/tests/browser-2.1.js
index 4ba938e..1ae3408 100644
--- a/tests/browser-2.1.js
+++ b/tests/browser-2.1.js
@@ -238,6 +238,68 @@ async function openOriginal( page ) {
);
const firstBranchId = await createBranchFromCurrentOriginal( page );
+
+ // 2.1.1 branch-management UI: verify the original card and the real list-table view.
+ await openOriginal( page );
+ const originalWithNewBranch = await branchStatus( page, originalId );
+ const firstBranchCard = originalWithNewBranch.branches?.find(
+ ( branch ) => branch.id === firstBranchId
+ );
+ check(
+ Boolean( firstBranchCard?.creator && firstBranchCard?.modified_human ),
+ 'Original status exposes branch creator and modified display metadata'
+ );
+ check(
+ await visible(
+ page.getByText( 'Existing branches (1)', { exact: true } )
+ ),
+ 'Original branch panel renders the exact existing-branch count'
+ );
+ check(
+ await visible(
+ page.getByText( `Branch #${ firstBranchId }`, { exact: true } )
+ ),
+ 'Original branch card renders the branch ID'
+ );
+ check(
+ await visible(
+ page.getByText( firstBranchCard.modified_human, { exact: true } )
+ ),
+ 'Original branch card renders the human-readable modified time'
+ );
+ check(
+ await visible(
+ page.getByText( '0 branch changes · 0 conflicts', {
+ exact: true,
+ } )
+ ),
+ 'Original branch card renders change and conflict counts'
+ );
+
+ const listPage = await context.newPage();
+ await listPage.goto( `${ baseUrl }/wp-admin/edit.php?wbfp_view=branches`, {
+ waitUntil: 'domcontentloaded',
+ } );
+ check(
+ await visible(
+ listPage
+ .locator( '.subsubsub a.current' )
+ .filter( { hasText: 'Existing branches' } )
+ ),
+ 'Posts list marks the dedicated Existing branches view as current'
+ );
+ check(
+ await visible( listPage.locator( `#post-${ firstBranchId }` ) ) &&
+ ! ( await visible( listPage.locator( `#post-${ originalId }` ) ) ),
+ 'Dedicated Existing branches view shows the branch without mixing in the original'
+ );
+ await listPage.close();
+
+ await page.goto(
+ `${ baseUrl }/wp-admin/post.php?post=${ firstBranchId }&action=edit`,
+ { waitUntil: 'domcontentloaded' }
+ );
+ await waitForEditor( page, firstBranchId );
check(
await visible(
page.getByRole( 'button', {
@@ -301,6 +363,21 @@ async function openOriginal( page ) {
Boolean( reviewedStatus.review_token ),
'Real editor status includes a reviewed-state token'
);
+ check(
+ Boolean(
+ reviewedStatus.review_values?.['post.post_content']?.branch
+ ),
+ 'Real editor REST status exposes the reviewed branch content value'
+ );
+ const originalWithBranch = await branchStatus( page, originalId );
+ check(
+ originalWithBranch.branches?.some(
+ ( branch ) =>
+ branch.id === firstBranchId &&
+ Boolean( branch.modified_human )
+ ),
+ 'Original status exposes branch ID and modified display metadata'
+ );
// Change the branch after review. The first merge click must not merge.
await editBranch( page, {
diff --git a/tests/capture-docs-2.1.js b/tests/capture-docs-2.1.js
index d6da9b8..48bef9c 100644
--- a/tests/capture-docs-2.1.js
+++ b/tests/capture-docs-2.1.js
@@ -278,7 +278,7 @@ async function shot( page, number ) {
waitUntil: 'domcontentloaded',
} );
await waitForEditor( page, originalId );
- await page.getByText( 'Existing branches', { exact: true } ).waitFor( {
+ await page.getByText( 'Existing branches' ).waitFor( {
state: 'visible',
timeout: 10000,
} );
diff --git a/tests/capture-tutorial-edge-extra.js b/tests/capture-tutorial-edge-extra.js
index 25437b4..ac10afd 100644
--- a/tests/capture-tutorial-edge-extra.js
+++ b/tests/capture-tutorial-edge-extra.js
@@ -245,6 +245,29 @@ const h = require( './tutorial-helpers' );
timeout: 10000,
} );
}
+
+ const taxonomyReview = page
+ .locator( '.wbfp-compare__field' )
+ .filter( { has: page.getByText( 'Categories', { exact: true } ) } )
+ .first();
+ await taxonomyReview.locator( 'summary' ).click();
+ await taxonomyReview
+ .getByText( 'Tutorial category', { exact: true } )
+ .waitFor( { state: 'visible', timeout: 10000 } );
+
+ const imageReview = page
+ .locator( '.wbfp-compare__field' )
+ .filter( { has: page.getByText( 'Featured image', { exact: true } ) } )
+ .first();
+ await imageReview.locator( 'summary' ).click();
+ await imageReview
+ .locator( 'img.wbfp-compare__thumbnail' )
+ .first()
+ .waitFor( { state: 'visible', timeout: 10000 } );
+
+ if ( ( await page.getByText( 'branch metadata value', { exact: true } ).count() ) > 0 ) {
+ throw new Error( 'Arbitrary custom-meta value leaked into merge review UI.' );
+ }
await h.shot(
page,
'35-taxonomy-meta-featured-image.png',
diff --git a/tests/wp-real-2.1.php b/tests/wp-real-2.1.php
index a12d51a..87da952 100644
--- a/tests/wp-real-2.1.php
+++ b/tests/wp-real-2.1.php
@@ -448,6 +448,167 @@ function wbfp_snapshot_hash( int $post_id ): string {
$safe_actions = $admin_ui->row_actions( array(), get_post( $ui_safe_branch ) );
wbfp_check( isset( $safe_actions['wbfp_merge'] ) && ! isset( $safe_actions['wbfp_review'] ), 'List row keeps normal merge for rebase-available non-conflicting state' );
+ $safe_states = $admin_ui->post_states( array(), get_post( $ui_safe_branch ) );
+ wbfp_check(
+ isset( $safe_states['wbfp_branch'] )
+ && false !== strpos( $safe_states['wbfp_branch'], '#' . $ui_safe_original )
+ && false !== strpos( $safe_states['wbfp_branch'], get_the_title( $ui_safe_original ) ),
+ 'Branch list state identifies the original by ID and title'
+ );
+
+ $status_request = new WP_REST_Request( 'GET', '/wbfp/v1/posts/' . $ui_safe_original . '/status' );
+ $status_request->set_param( 'id', $ui_safe_original );
+ $original_status = $rest->status( $status_request )->get_data();
+ wbfp_check(
+ ! empty( $original_status['branches'][0]['modified_human'] ),
+ 'Original status includes a human-readable branch modified time'
+ );
+
+ $previous_screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+ $previous_wp_query = $GLOBALS['wp_the_query'] ?? null;
+ set_current_screen( 'edit-post' );
+ $_GET['wbfp_view'] = 'branches';
+ $branch_views = $admin_ui->branch_views( array() );
+ wbfp_check( isset( $branch_views['wbfp_branches'] ), 'Posts list exposes a dedicated existing-branches view' );
+
+ $page_filter_original = wbfp_make_post( array( 'post_type' => 'page', 'post_title' => 'Branch view page' ) );
+ $page_filter_branch = $branches->create( $page_filter_original );
+ wbfp_check( ! is_wp_error( $page_filter_branch ), 'Create page branch for Pages list filter test' );
+ $page_filter_branch = (int) $page_filter_branch;
+ $created_posts[] = $page_filter_branch;
+ set_current_screen( 'edit-page' );
+ $page_branch_views = $admin_ui->branch_views( array() );
+ wbfp_check( isset( $page_branch_views['wbfp_branches'] ), 'Pages list exposes a dedicated existing-branches view' );
+ set_current_screen( 'edit-post' );
+
+ $branch_query = new WP_Query();
+ $GLOBALS['wp_the_query'] = $branch_query;
+ $admin_ui->filter_branch_view_query( $branch_query );
+ $branch_meta_query = $branch_query->get( 'meta_query' );
+ wbfp_check(
+ is_array( $branch_meta_query )
+ && 'OR' === ( $branch_meta_query['relation'] ?? '' )
+ && Branch_Service::META_ORIGINAL_ID === ( $branch_meta_query[0]['key'] ?? '' ),
+ 'Dedicated branch view filters the main list query by branch relationship'
+ );
+
+ $post_branch_query = new WP_Query(
+ array(
+ 'post_type' => 'post',
+ 'post_status' => array( 'draft', 'pending', 'private', 'future' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => $branch_meta_query,
+ )
+ );
+ $post_branch_ids = array_map( 'intval', $post_branch_query->posts );
+ wbfp_check(
+ in_array( $ui_safe_branch, $post_branch_ids, true )
+ && ! in_array( $ui_safe_original, $post_branch_ids, true )
+ && ! in_array( $page_filter_branch, $post_branch_ids, true ),
+ 'Posts branch query returns branches without leaking originals or Pages'
+ );
+
+ $page_branch_query = new WP_Query(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => array( 'draft', 'pending', 'private', 'future' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => $branch_meta_query,
+ )
+ );
+ $page_branch_ids = array_map( 'intval', $page_branch_query->posts );
+ wbfp_check(
+ in_array( $page_filter_branch, $page_branch_ids, true )
+ && ! in_array( $ui_safe_branch, $page_branch_ids, true ),
+ 'Pages branch query stays isolated from Post branches'
+ );
+
+ $author_id = wp_create_user( 'wbfp-branch-author', 'wbfp-test-password', 'wbfp-branch-author@example.test' );
+ if ( is_wp_error( $author_id ) ) {
+ throw new RuntimeException( $author_id->get_error_message() );
+ }
+ $author_id = (int) $author_id;
+ $created_users[] = $author_id;
+ ( new WP_User( $author_id ) )->set_role( 'author' );
+ wp_set_current_user( $author_id );
+
+ $author_original = wbfp_make_post(
+ array(
+ 'post_author' => $author_id,
+ 'post_title' => 'Author-owned branch source',
+ )
+ );
+ $author_branch = $branches->create( $author_original );
+ wbfp_check( ! is_wp_error( $author_branch ), 'Author can create a branch for an editable own post' );
+ $author_branch = (int) $author_branch;
+ $created_posts[] = $author_branch;
+
+ $author_legacy_original = wbfp_make_post(
+ array(
+ 'post_author' => $author_id,
+ 'post_title' => 'Author-owned legacy branch source',
+ )
+ );
+ $author_legacy_branch = wbfp_make_post(
+ array(
+ 'post_author' => $author_id,
+ 'post_status' => 'draft',
+ 'post_title' => 'Author-owned legacy branch',
+ )
+ );
+ update_post_meta( $author_legacy_branch, '_original_post_id', $author_legacy_original );
+
+ $author_branch_query = new WP_Query();
+ $GLOBALS['wp_the_query'] = $author_branch_query;
+ $author_branch_query->set( 'post_type', 'post' );
+ $admin_ui->filter_branch_view_query( $author_branch_query );
+ wbfp_check(
+ $author_id === (int) $author_branch_query->get( 'author' ),
+ 'Existing-branches query explicitly scopes users without edit_others_posts to their own branches'
+ );
+ $author_branch_query->query( $author_branch_query->query_vars );
+ $author_branch_ids = array_map( 'intval', wp_list_pluck( $author_branch_query->posts, 'ID' ) );
+ wbfp_check(
+ in_array( $author_branch, $author_branch_ids, true )
+ && in_array( $author_legacy_branch, $author_branch_ids, true )
+ && ! in_array( $ui_safe_branch, $author_branch_ids, true ),
+ 'Existing-branches query returns current and legacy own branches without leaking another author branch'
+ );
+ $author_views = $admin_ui->branch_views( array() );
+ wbfp_check(
+ isset( $author_views['wbfp_branches'] )
+ && false !== strpos( $author_views['wbfp_branches'], '(2)' ),
+ 'Existing-branches view count matches the branches visible to the current author'
+ );
+
+ wp_set_current_user( $admin_id );
+ wp_update_post(
+ array(
+ 'ID' => $author_original,
+ 'post_author' => $admin_id,
+ 'post_status' => 'private',
+ )
+ );
+ wp_set_current_user( $author_id );
+ wbfp_check(
+ ! current_user_can( 'read_post', $author_original ),
+ 'Branch author cannot read a private original reassigned to another user'
+ );
+ $private_original_states = $admin_ui->post_states( array(), get_post( $author_branch ) );
+ wbfp_check(
+ isset( $private_original_states['wbfp_branch'] )
+ && false !== strpos( $private_original_states['wbfp_branch'], '#' . $author_original )
+ && false === strpos( $private_original_states['wbfp_branch'], 'Author-owned branch source' ),
+ 'Branch list state does not leak a private original title without read permission'
+ );
+ wp_set_current_user( $admin_id );
+
+ unset( $_GET['wbfp_view'] );
+ $GLOBALS['wp_the_query'] = $previous_wp_query;
+ set_current_screen( $previous_screen ? $previous_screen->id : 'front' );
+
$previous_post = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = get_post( $ui_safe_branch );
ob_start();
@@ -497,6 +658,46 @@ function wbfp_snapshot_hash( int $post_id ): string {
$created_posts[] = $future_branch;
wbfp_check( 'draft' === get_post_status( $future_branch ), 'Branch created from a scheduled original is draft' );
+ // Comment and ping status review values on a post type that natively supports them.
+ $status_original = wbfp_make_post(
+ array(
+ 'post_title' => 'Status review source',
+ 'comment_status' => 'open',
+ 'ping_status' => 'open',
+ )
+ );
+ $status_branch = $branches->create( $status_original );
+ wbfp_check( ! is_wp_error( $status_branch ), 'Create branch for comment and ping status review' );
+ $status_branch = (int) $status_branch;
+ $created_posts[] = $status_branch;
+ wp_update_post(
+ array(
+ 'ID' => $status_branch,
+ 'comment_status' => 'closed',
+ 'ping_status' => 'closed',
+ )
+ );
+ $status_review_request = new WP_REST_Request( 'GET', '/wbfp/v1/posts/' . $status_branch . '/status' );
+ $status_review_request->set_param( 'id', $status_branch );
+ $status_review_data = $rest->status( $status_review_request )->get_data();
+ $status_review_values = $status_review_data['review_values'] ?? array();
+ $open_status_label = translate( 'Open' );
+ $closed_status_label = translate( 'Closed' );
+ wbfp_check(
+ $open_status_label === ( $status_review_values['post.comment_status']['base'] ?? '' )
+ && $closed_status_label === ( $status_review_values['post.comment_status']['branch'] ?? '' ),
+ 'Merge review exposes readable comment-status values'
+ );
+ wbfp_check(
+ $open_status_label === ( $status_review_values['post.ping_status']['base'] ?? '' )
+ && $closed_status_label === ( $status_review_values['post.ping_status']['branch'] ?? '' ),
+ 'Merge review exposes readable ping-status values'
+ );
+ $status_merge = $merges->merge( $status_branch, false );
+ wbfp_check( $status_original === $status_merge, 'Comment and ping status branch merges normally' );
+ wbfp_check( 'closed' === get_post( $status_original )->comment_status, 'Comment status merges from the reviewed branch value' );
+ wbfp_check( 'closed' === get_post( $status_original )->ping_status, 'Ping status merges from the reviewed branch value' );
+
// Custom post type, custom taxonomy, custom meta and featured-image metadata.
register_post_type(
'wbfp_story',
@@ -542,6 +743,42 @@ function wbfp_snapshot_hash( int $post_id ): string {
}
$thumb_a = wbfp_track_post( (int) $thumb_a );
$thumb_b = wbfp_track_post( (int) $thumb_b );
+ update_post_meta( $thumb_a, '_wp_attached_file', '2026/09/featured-image-a.jpg' );
+ update_post_meta(
+ $thumb_a,
+ '_wp_attachment_metadata',
+ array(
+ 'width' => 1200,
+ 'height' => 800,
+ 'file' => '2026/09/featured-image-a.jpg',
+ 'sizes' => array(
+ 'thumbnail' => array(
+ 'file' => 'featured-image-a-150x150.jpg',
+ 'width' => 150,
+ 'height' => 150,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ )
+ );
+ update_post_meta( $thumb_b, '_wp_attached_file', '2026/09/featured-image-b.jpg' );
+ update_post_meta(
+ $thumb_b,
+ '_wp_attachment_metadata',
+ array(
+ 'width' => 1200,
+ 'height' => 800,
+ 'file' => '2026/09/featured-image-b.jpg',
+ 'sizes' => array(
+ 'thumbnail' => array(
+ 'file' => 'featured-image-b-150x150.jpg',
+ 'width' => 150,
+ 'height' => 150,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ )
+ );
$cpt_original = wbfp_make_post(
array(
@@ -569,7 +806,14 @@ function wbfp_snapshot_hash( int $post_id ): string {
$cpt_branch_terms = wp_get_object_terms( $cpt_branch, 'wbfp_topic', array( 'fields' => 'ids' ) );
wbfp_check( array( (int) $topic['term_id'] ) === array_map( 'intval', $cpt_branch_terms ), 'Custom taxonomy assignment is copied to the branch' );
- wp_update_post( array( 'ID' => $cpt_branch, 'post_content' => 'Custom story branch content' ) );
+ wp_update_post(
+ array(
+ 'ID' => $cpt_branch,
+ 'post_content' => 'Custom story branch content',
+ 'menu_order' => 7,
+ 'post_password' => 'super-secret-test',
+ )
+ );
update_post_meta( $cpt_branch, 'wbfp_custom_meta', 'custom-meta-branch' );
delete_post_meta( $cpt_branch, 'wbfp_remove_meta' );
delete_post_meta( $cpt_branch, 'wbfp_multi_meta' );
@@ -577,6 +821,43 @@ function wbfp_snapshot_hash( int $post_id ): string {
add_post_meta( $cpt_branch, 'wbfp_multi_meta', 'multi-four' );
update_post_meta( $cpt_branch, '_thumbnail_id', $thumb_b );
wp_set_object_terms( $cpt_branch, array(), 'wbfp_topic', false );
+
+ $cpt_status_request = new WP_REST_Request( 'GET', '/wbfp/v1/posts/' . $cpt_branch . '/status' );
+ $cpt_status_request->set_param( 'id', $cpt_branch );
+ $cpt_status = $rest->status( $cpt_status_request )->get_data();
+ $review_values = $cpt_status['review_values'] ?? array();
+ wbfp_check(
+ isset( $review_values['taxonomies.wbfp_topic'] )
+ && false !== strpos( $review_values['taxonomies.wbfp_topic']['base'], 'Branch taxonomy topic' )
+ && '—' === $review_values['taxonomies.wbfp_topic']['branch'],
+ 'Merge review exposes readable taxonomy term changes'
+ );
+ wbfp_check(
+ isset( $review_values['meta._thumbnail_id'] )
+ && false !== strpos( $review_values['meta._thumbnail_id']['base'], 'Featured image A' )
+ && false !== strpos( $review_values['meta._thumbnail_id']['branch'], 'Featured image B' ),
+ 'Merge review exposes readable featured-image identity changes'
+ );
+ wbfp_check(
+ ! empty( $review_values['meta._thumbnail_id']['base_preview_url'] )
+ && ! empty( $review_values['meta._thumbnail_id']['branch_preview_url'] )
+ && $review_values['meta._thumbnail_id']['base_preview_url'] !== $review_values['meta._thumbnail_id']['branch_preview_url'],
+ 'Merge review exposes distinct thumbnail preview URLs when image data is available'
+ );
+ wbfp_check(
+ '7' === ( $review_values['post.menu_order']['branch'] ?? '' ),
+ 'Merge review exposes menu-order values'
+ );
+ wbfp_check(
+ '••••••' === ( $review_values['post.post_password']['branch'] ?? '' )
+ && false === strpos( wp_json_encode( $review_values ), 'super-secret-test' ),
+ 'Merge review masks post passwords instead of exposing them through REST'
+ );
+ wbfp_check(
+ ! isset( $review_values['meta.wbfp_custom_meta'] ),
+ 'Merge review keeps arbitrary custom-meta values private'
+ );
+
$cpt_merge = $merges->merge( $cpt_branch, false );
wbfp_check( $cpt_original === $cpt_merge, 'Custom post type branch merges normally' );
wbfp_check( 'Custom story branch content' === get_post( $cpt_original )->post_content, 'Custom post type editorial content merges' );