Commit 50ea746d18c for woocommerce
commit 50ea746d18c1c4f13d0b811af6b1cbffab9bd1b8
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Tue May 5 17:15:34 2026 +0100
[Email Editor] Add change-summary REST endpoint for template updates (RSM-142) (#64484)
* [Email Editor] Add change-summary REST endpoint and service
Add WCEmailTemplateChangeSummary service that produces a localized
block-tree diff between a merchant's woo_email post content and the
current canonical core render, exposed as
GET /woocommerce-email-editor/v1/emails/{id}/change-summary.
Algorithm: DFS-flatten both block trees, normalize known namespace
aliases (woo/email-content -> woocommerce/email-content), run an LCS
over the resulting block-name sequences, classify the residue as added
/ removed / copy / structural. Result is cached in a transient keyed
on the combined post + core content hashes plus locale, so any
merchant edit or core bump invalidates automatically.
A summary-inversion guard returns a release-notes fallback when the
shape suggests a heavily customized post rather than a real core
update (>= 5 unmatched blocks on one side, 0 copy edits, post total
>= 1.5x core total). Without a stored old-core snapshot we cannot
attribute drift confidently in that case.
Includes 8 service tests covering happy path, alias normalization,
depth-asymmetric Group wraps, LCS cascade resistance on uniform
paragraph runs, inversion guard, fallbacks, identical content, and
cache invalidation. Plus 2 controller tests for the new route.
Why now: feeds the upcoming RSM-141 banner subtitle and RSM-144 apply
confirmation modal. Per-block conflict resolution UI and attribute-
level diffs stay scoped to RSM-143's selective merge engine.
* [Email Editor] Fix change-summary deltas to follow yours->core direction
The original implementation framed every delta from "core = original,
post = current edits" — the opposite of how the design and consumers
read these payloads. Caught during live testing on a dev env when the
heading copy_change came back with `before` = core's text and `after`
= post's text. The full set of fields had the same flip.
Two related bug instances, same root cause:
1. `before` / `after` on copy_changes were swapped. `before` was core
(target), `after` was post (yours). Should be `before` = yours
(merchant's current text, the red-minus row in the design's diff)
and `after` = core (the green-plus row, the value after applying).
2. `added_blocks` / `removed_blocks` had the same swap. Unmatched core
entries went to `removed_blocks` (framing them as merchant
deletions); unmatched post entries went to `added_blocks` (framing
them as merchant additions). The natural reading for the apply-
confirmation modal and for RSM-143's selective merge is the
opposite: `added_blocks` = blocks that would be added to the post
if the update were applied (in core, not in post); `removed_blocks`
= blocks that would be removed by applying (in post, not in core).
3. The matched-pair "Moved %s into %s" description named post's parent
(where the block currently sits) instead of core's (where it would
land after applying). Also flipped.
4. The summary-inversion guard reads from the same arrays, so its
field references swapped accordingly. The constant
INVERSION_GUARD_ADDED_THRESHOLD is renamed to
INVERSION_GUARD_THRESHOLD to drop the directional baggage.
Test assertions updated to match the new direction. REST schema
descriptions for added_blocks / removed_blocks rewritten to spell out
the "would be added/removed if the update were applied" semantic so a
future RSM-143 implementer is not misled by the field names.
`removed_blocks` keeps its name because it reads correctly for the
RSM-144 wholesale-apply confirmation modal ("these blocks would be
removed"). RSM-143's selective merge re-tags the same data as "Keep
yours" auto-resolved entries — that translation is documented in the
implementation plan rather than baked into the field name.
* [Email Editor] Score change-summary LCS by text similarity
Name-only LCS treats every block of the same type as an equally valid
match candidate. On the uniform paragraph runs typical of WooCommerce
email templates, that means a single edit to one paragraph can land the
wrong pair as the matched copy_change — the merchant's edited paragraph
gets paired with an unrelated paragraph elsewhere in the post, putting
nonsense text into `before`. Caught on a real env: post 1239 paired
core's "You've received a new order from..." with post[3] "Some extra
content here." instead of with post[1] "Nice. You've received...",
making the copy_change unreadable.
Fix: add a tiny Jaccard word-similarity bonus on each diagonal step in
the LCS DP. Bonus ceiling is 0.001 per match — far below the 1.0
contribution of a name match — so cardinality is preserved. Among
equally-sized LCS alignments, the one with highest total text similarity
wins. The merchant's edited paragraph now pairs with core's original
because they share most of their words.
Also caught a subtler bug in the DP itself while writing the test: when
names match, the DP must still take max(diagonal, up, left). With
similarity bonuses, the diagonal can score lower than `up` or `left` if
those have already accumulated higher bonuses on a different alignment;
unconditionally taking the diagonal would discard the better path.
Standard cardinality-only LCS gets away with this because each match
adds exactly 1, but the bonus variant doesn't.
Separately, rephrase the copy_changes disambiguator from
"Updated wording in Paragraph (1 of 2)" to
"Updated wording in Paragraph 1 of 2". The parens form parses
ambiguously as either "the 1st of 2 paragraphs in the template" or
"1 of 2 edits made"; without the parens, "Paragraph 1 of 2" reads as
natural English for the former.
Adds test_summarize_prefers_text_similar_pairing_in_uniform_block_runs
to lock in the new behaviour. The fixture has one core paragraph and
three post paragraphs where one shares almost all words with core and
the other two share none. Without the similarity bonus the DP picks
the wrong post paragraph; with it, the high-overlap pair wins.
* [Email Editor] Treat in-sync templates as zero-result, not fallback
The original implementation set `is_fallback: true` and emitted a "No
visible changes since this template was last applied." sentinel line
whenever post and core hashed to the same content. That contradicted
the documented contract: `is_fallback` reads "true when the diff could
not be produced," but the in-sync path produced a successful diff that
just happened to find no changes.
Consumers gating on `is_fallback` would misclassify a fully up-to-date
template as "we couldn't compute anything" and render the degraded
release-notes UI. Caught during the code review on the draft PR.
Fix: the in-sync path now returns a successful zero-result — empty
structured arrays, empty `summary_lines`, `is_fallback: false`. Any
"you're up to date" copy is the consumer's responsibility, derived
from the absence of deltas. The endpoint stays honest about what it
provides: data, not UI text.
The cache lookup also moves above the in-sync hash check so the
zero-result flows through the same transient cache as every other
result. That makes the class docblock's claim that "result is cached
in a transient keyed on the combined post + core content hashes plus
locale" accurate for every code path, not just the diff path.
Adds `test_summarize_caches_in_sync_zero_result` to lock in the cache
behaviour. Renames `test_summarize_returns_no_changes_when_post_equals_core`
to `test_summarize_returns_empty_payload_when_post_equals_core` and
updates its assertions to the new shape.
Also drops the unused `$post_names` array_map call in `diff_records()`
— leftover from when `lcs_matches()` took name arrays before the
similarity-scoring refactor changed it to take records.
Verified live on wp-env post 740 (`customer_completed_order`,
in-sync): payload returns `is_fallback: false` with all arrays empty,
matching the new contract.
* [Email Editor] Add block paths, suppress redundant wrappers, audit docs
Three related changes drawn from the Gutenberg `@wordpress/sync` review
of this PR (mergeCrdtBlocks parallels in `crdt-blocks.ts`).
1. Stable per-block paths on every diff entry (review point #4).
added_blocks and removed_blocks are now `[{label, path}, …]` instead
of bare string arrays; copy_changes and structural_changes (except
`kind: 'reorder'`) carry a `path` too. The path is the index path
through the parsed block tree on the side where the relevant block
exists — core-side for adds, post-side for removes / copy / matched-
pair moves. RSM-143's selective-merge UI uses this to map per-block
"Keep yours / Use core" choices back to specific blocks during
merge, instead of having to re-walk both trees with the same
identity heuristic to attach UI state. Reorder entries omit `path`
because they describe a structural fact, not a single block.
2. Suppress redundant wrapper entries when a matched pair covers the
same physical edit (review point #3). The matched-pair pass now
collects the parent names that produced a "Moved %s into %s" entry,
and the unmatched pass skips wrapper entries on the same side
whose block name appears in that set. Without this, a Group wrap of
an existing Paragraph emitted both "Removed Group wrapper" and
"Moved Paragraph into top level" for the same edit. Pass order is
flipped so matched pairs run first, which is what the suppression
needs to know about. The reverse case — a Group with no matched
children, e.g. wrapping unrelated content — still keeps the wrapper
entry, locked in by `test_summarize_keeps_wrapper_entry_when_no_matched_pair_covers_it`.
The Gutenberg review proposed full recursive diff at every container
(which is what `mergeCrdtBlocks` does); this is a smaller, cheaper
variant that delivers the same UI-side benefit — one entry per
physical edit instead of two — without restructuring the algorithm.
3. Doc-comment honesty audit. The audit caught four mismatches between
docblocks and current behaviour:
- Class-level cache-key description omitted post_id (the actual
transient name is `wc_email_change_summary_{post_id}_{md5(…)}`).
- flatten_blocks() said "leaf descriptors" but emits every block in
DFS pre-order, including non-leaf structural wrappers like
core/group — the diff classifier explicitly relies on receiving
those.
- to_summary_lines() referenced the `(N of M)` disambiguator format,
stale from before we dropped the parens to read as natural English
("Updated wording in Paragraph 1 of 2").
- get_change_summary_response() said the 404 path mirrors
get_default_content_response, which is true for the 404 path itself
but NOT for valid-but-not-sync-enabled posts: default-content
returns 200 + content; change-summary returns 200 + is_fallback:
true. The mismatch was previously flagged in the code review and
would mislead RSM-143's implementer.
REST schema in EmailApiController updated to match the new
added_blocks / removed_blocks / copy_changes / structural_changes
shapes. Existing test assertions adapted to read labels off the rich
entries via array_column. New test
`test_summarize_keeps_wrapper_entry_when_no_matched_pair_covers_it`
locks in the "don't over-suppress" invariant.
Verified live on wp-env: post 740 (in-sync) returns empty arrays with
is_fallback: false; post 739 (heavily customized) returns a rich
payload with deep paths like [4,0], [4,1], [12,0,0] reflecting nested
block locations exactly where the merchant has them.
* [Email Editor] Address CodeRabbit review on change-summary diff
Three issues raised in the bot review on PR #64484, all confirmed real
against the current code:
1. Reorder pass collapsed distinct namespaces under a shared label.
Pairing keyed on `$entry['label']`, which is the output of
`block_label()` after the namespace prefix is stripped — so
`vendor-a/header` and `vendor-b/header` both collapsed to `Header`
and the two unrelated entries got reclassified as a single
`Reordered Header`. Fix: thread the post-alias-normalized block name
onto each entry as `name` (e.g. `core/heading`,
`woocommerce/email-content`), key the reorder map on `name`, and
compute the localized label only when building the description.
`name` is also useful info on its own — RSM-143 can map it to icons
or block-definition lookups without re-walking the parsed tree.
Locked in by `test_summarize_reorder_pairs_by_normalized_name_not_humanized_label`.
2. `strtolower()` in `similarity_score()` is ASCII-only. For
translated email templates with accented / Cyrillic / Greek copy,
the same word in different cases (e.g. `"Établie"` vs `"établie"`)
wouldn't match in the Jaccard intersection, the LCS tiebreaker
would lose its signal, and the merchant would get the same
counter-intuitive paragraph pairings the similarity bonus was meant
to fix. Swap to `wc_strtolower()` — WooCommerce's wrapper that uses
`mb_strtolower()` with an ASCII fallback.
3. Singular catalog strings used the indefinite article: "Added a %s
block" / "Removed a %s block". Renders as "Added a Image block" /
"Added a Email content block" — wrong article, since English a/an
depends on phonetics that the catalog can't infer at format time.
Many target locales have no equivalent article at all. Drop the
article in singular: "Added Image block" / "Removed Image block".
Plural already reads "Added 3 Image blocks" without an article, so
both forms are now stylistically consistent.
REST schema, class docblock, and an existing test assertion all
updated to reflect the new shape and string. PHPStan clean, lint
clean, 29 tests / 202 assertions pass.
* [Email Editor] Address review feedback on change-summary PR
Three small follow-ups from review:
- Bump `@since` annotations on the new class and methods from `10.8.0` to
`10.9.0` to match the current `class-woocommerce.php` version and the
10.9.0 milestone. `reset_response()` keeps `@since 10.8.0` because that
endpoint shipped with PR #64450 before this branch.
- Add `test_register_routes_registers_change_summary_endpoint` mirroring
the reset sibling. The test calls `setExpectedIncorrectUsage` for
`register_rest_route` because the second test in the file inherits a
`rest_get_server()` instance from the first; without the opt-in,
WordPress fires `_doing_it_wrong` when `register_rest_route()` runs
outside `rest_api_init`.
- Add `@testdox` to the twelve methods in `WCEmailTemplateChangeSummaryTest`
to match the surrounding `EmailApiControllerTest` convention.
diff --git a/plugins/woocommerce/changelog/rsm-142-change-summary b/plugins/woocommerce/changelog/rsm-142-change-summary
new file mode 100644
index 00000000000..dd28ab0175c
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-142-change-summary
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add change-summary REST endpoint and service for surfacing block email template updates.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index be4e396fdd5..2f543779d86 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -5,8 +5,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\EmailEditor;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
-use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WC_Email;
@@ -306,6 +306,27 @@ class EmailApiController {
'schema' => array( $this, 'get_reset_schema' ),
)
);
+
+ register_rest_route(
+ 'woocommerce-email-editor/v1',
+ '/emails/(?P<id>\d+)/change-summary',
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_change_summary_response' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The ID of the woo_email post.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ 'schema' => array( $this, 'get_change_summary_schema' ),
+ )
+ );
}
/**
@@ -465,4 +486,162 @@ class EmailApiController {
return new WP_REST_Response( $result, 200 );
}
+
+ /**
+ * Get the schema for the change-summary endpoint response.
+ *
+ * @return array
+ */
+ public function get_change_summary_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'woo_email_change_summary',
+ 'type' => 'object',
+ 'properties' => array(
+ 'version_from' => array(
+ 'description' => __( 'The template version stamped on the post (may be empty for pre-backfill posts).', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'version_to' => array(
+ 'description' => __( 'The current core template version recorded in the sync registry.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'added_blocks' => array(
+ 'description' => __( 'Blocks that would be added to the merchant post if the update were applied (in core, not in post). `name` is the post-alias-normalized block name (e.g. `core/heading`); `label` is its humanized form for display; `path` is the core-side index path through the parsed block tree.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'path' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => array( 'integer', 'string' ) ),
+ ),
+ ),
+ ),
+ 'readonly' => true,
+ ),
+ 'removed_blocks' => array(
+ 'description' => __( 'Blocks that would be removed from the merchant post if the update were applied (in post, not in core). Same fields as `added_blocks`; `path` is the post-side index path.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'path' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => array( 'integer', 'string' ) ),
+ ),
+ ),
+ ),
+ 'readonly' => true,
+ ),
+ 'copy_changes' => array(
+ 'description' => __( 'Block-level copy edits, truncated to 120 chars per side. `before` is the merchant\'s current text; `after` is the canonical core text. `path` is the post-side index path.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'block' => array( 'type' => 'string' ),
+ 'before' => array( 'type' => 'string' ),
+ 'after' => array( 'type' => 'string' ),
+ 'occurrence' => array( 'type' => 'integer' ),
+ 'total' => array( 'type' => 'integer' ),
+ 'path' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => array( 'integer', 'string' ) ),
+ ),
+ ),
+ ),
+ 'readonly' => true,
+ ),
+ 'structural_changes' => array(
+ 'description' => __( 'Structural deltas (reorder / nest) between the two trees. `path` is omitted on `kind: "reorder"` entries.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'kind' => array( 'type' => 'string' ),
+ 'description' => array( 'type' => 'string' ),
+ 'path' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => array( 'integer', 'string' ) ),
+ ),
+ ),
+ ),
+ 'readonly' => true,
+ ),
+ 'summary_lines' => array(
+ 'description' => __( 'Pre-localized one-liners ready for direct render.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ 'readonly' => true,
+ ),
+ 'is_fallback' => array(
+ 'description' => __( 'True when the diff could not be produced and a generic message is returned instead.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ ),
+ 'cache_hit' => array(
+ 'description' => __( 'Diagnostic flag indicating whether the response came from the transient cache.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Return a localized summary of differences between the merchant's
+ * `woo_email` post and the canonical core render.
+ *
+ * Thin wrapper over {@see WCEmailTemplateChangeSummary::summarize()}. The
+ * 404 path mirrors {@see self::get_default_content_response()} — when the
+ * email type cannot be resolved from the post ID, the post is either
+ * non-existent or not a `woo_email`.
+ *
+ * The 200 path differs for valid posts that are NOT in
+ * {@see WCEmailTemplateSyncRegistry}: `default-content` returns the
+ * canonical content; `change-summary` returns a fallback payload with
+ * `is_fallback: true` and a generic release-notes line because no
+ * registered version is available to diff against. Consumers gating on
+ * `is_fallback` should treat that case as "no actionable summary,"
+ * regardless of HTTP status.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ *
+ * @since 10.9.0
+ */
+ public function get_change_summary_response( WP_REST_Request $request ) {
+ if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+ return new WP_Error(
+ 'woocommerce_email_editor_not_initialized',
+ __( 'Email editor is not initialized.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+ $email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+ $email = $this->get_email_by_type( $email_type ?? '' );
+
+ if ( ! $email ) {
+ return new WP_Error(
+ 'woocommerce_email_not_found',
+ __( 'No email found for the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return new WP_REST_Response(
+ WCEmailTemplateChangeSummary::summarize( $post_id ),
+ 200
+ );
+ }
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
new file mode 100644
index 00000000000..3fbda035e44
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -0,0 +1,899 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Produces a localized summary of differences between a merchant's `woo_email`
+ * post content and the current canonical core render of the same email.
+ *
+ * Algorithm: DFS-flatten both block trees → normalize known namespace aliases
+ * (e.g. `woo/email-content` → `woocommerce/email-content`) → run an LCS over
+ * the resulting block-name sequences → classify the residue as added /
+ * removed / copy / structural. Result is cached in a transient keyed on the
+ * post ID, the post and core content hashes, and the active locale, so any
+ * merchant edit or core bump invalidates automatically.
+ *
+ * Hash input parity with {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}
+ * is guaranteed by construction — both sides route through the same canonical
+ * render, identical to the divergence detector.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.9.0
+ */
+class WCEmailTemplateChangeSummary {
+ /**
+ * Transient TTL.
+ *
+ * Long-lived because the cache key is content-hash-bound; production
+ * invalidation is automatic on any post or core change.
+ */
+ private const CACHE_TTL = DAY_IN_SECONDS;
+
+ /**
+ * Maximum length of each side of a copy change, in characters.
+ */
+ private const COPY_TRUNCATE_CHARS = 120;
+
+ /**
+ * Summary-inversion guard: minimum count of post-side unmatched blocks
+ * (with no copy changes and a heavily larger post) before we refuse to
+ * confidently attribute the diff to "core changed."
+ */
+ private const INVERSION_GUARD_THRESHOLD = 5;
+
+ /**
+ * Summary-inversion guard: minimum post-to-core record-count ratio.
+ */
+ private const INVERSION_GUARD_RATIO = 1.5;
+
+ /**
+ * Block-name aliases. Used to fold known namespace renames into a single
+ * identity so the diff doesn't surface them as add+remove pairs.
+ *
+ * Keep this map intentionally small — extend only when a real alias is
+ * observed in the wild.
+ *
+ * @var array<string,string>
+ */
+ private const BLOCK_NAME_ALIASES = array(
+ 'woo/email-content' => 'woocommerce/email-content',
+ );
+
+ /**
+ * Block names that act purely as structural wrappers in email templates.
+ * When one of these appears unmatched on a single side it is reported as a
+ * `nest` structural change rather than as an `added_blocks`/`removed_blocks`
+ * entry.
+ *
+ * @var array<string,bool>
+ */
+ private const STRUCTURAL_BLOCK_NAMES = array(
+ 'core/group' => true,
+ 'core/columns' => true,
+ 'core/column' => true,
+ 'core/row' => true,
+ );
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static $logger = null;
+
+ /**
+ * Produce a structured + localized summary of differences between the
+ * merchant's `woo_email` post and the current canonical core render.
+ *
+ * Returned payload shape (documented for callers; typed as
+ * `array<string, mixed>` so internal helpers can return through it
+ * without expanding every union into the signature):
+ *
+ * All deltas are framed as "what would happen if the merchant applied the
+ * update," i.e. the "yours" → "core" direction:
+ *
+ * - `version_from` — `string` — `_wc_email_template_version` meta on the post (may be empty).
+ * - `version_to` — `string` — registry-side current version.
+ * - `added_blocks` — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be added to the post by applying (in core, not in post). `name` is the post-alias-normalized block name (e.g. `core/heading`); `label` is its humanized form for display; `path` is the core-side index path.
+ * - `removed_blocks` — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be removed from the post by applying (in post, not in core). Same field semantics as `added_blocks`; `path` is the post-side index path.
+ * - `copy_changes` — `array<int, array{block:string, before:string, after:string, occurrence:int, total:int, path:array<int|string>}>`.
+ * `before` is the merchant's current text; `after` is the canonical core text. `path` is the post-side index path.
+ * - `structural_changes` — `array<int, array{kind:string, description:string, path?:array<int|string>}>` — `path` is omitted for `kind: 'reorder'` entries.
+ * - `summary_lines` — `string[]` — pre-localized one-liners ready to render.
+ * - `is_fallback` — `bool` — true when the diff could not be produced.
+ * - `cache_hit` — `bool` — diagnostic.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ *
+ * @return array<string, mixed>
+ *
+ * @since 10.9.0
+ */
+ public static function summarize( int $post_id ): array {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post ) {
+ return self::fallback_payload( '', '' );
+ }
+
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $email_id = $posts_manager->get_email_type_from_post_id( $post_id );
+ if ( ! is_string( $email_id ) || '' === $email_id ) {
+ return self::fallback_payload( '', '' );
+ }
+
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+ if ( null === $sync_config ) {
+ return self::fallback_payload( '', '' );
+ }
+
+ $emails = $posts_manager->get_emails_by_id();
+ $email = $emails[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ return self::fallback_payload( '', (string) $sync_config['version'] );
+ }
+
+ $post_content = (string) $post->post_content;
+
+ try {
+ $core_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ } catch ( \Throwable $e ) {
+ self::get_logger()->error(
+ sprintf(
+ 'Email template change summary failed to compute canonical content for email "%s": %s',
+ $email_id,
+ $e->getMessage()
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'post_id' => $post_id,
+ 'context' => 'email_template_change_summary',
+ )
+ );
+ return self::fallback_payload(
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ (string) $sync_config['version']
+ );
+ }
+
+ $version_from = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
+ $version_to = (string) $sync_config['version'];
+
+ $post_hash = sha1( $post_content );
+ $core_hash = sha1( $core_content );
+
+ $cache_key = self::cache_key( $post_id, $post_hash, $core_hash, self::current_locale() );
+ $cached = get_transient( $cache_key );
+ if ( is_array( $cached ) ) {
+ $cached['cache_hit'] = true;
+ return $cached;
+ }
+
+ // In-sync zero-result: post and core hash to the same content. Successful
+ // diff with no deltas. `is_fallback` stays false (the docblock contract:
+ // fallback = "diff could not be produced," not "diff produced no
+ // changes"). Empty `summary_lines` lets consumers detect the no-op state
+ // by emptiness alone — they construct any "you're up to date" copy
+ // themselves.
+ if ( $post_hash === $core_hash ) {
+ $payload = self::empty_payload();
+ $payload['version_from'] = $version_from;
+ $payload['version_to'] = $version_to;
+ self::write_cache( $cache_key, $payload );
+ return $payload;
+ }
+
+ $post_records = self::flatten_blocks( parse_blocks( $post_content ) );
+ $core_records = self::flatten_blocks( parse_blocks( $core_content ) );
+
+ if ( empty( $post_records ) || empty( $core_records ) ) {
+ return self::fallback_payload( $version_from, $version_to );
+ }
+
+ $structured = self::diff_records( $core_records, $post_records );
+
+ // Summary-inversion guard: a heavily one-sided expansion on the post
+ // side looks like merchant work attributed to core. Without a stored
+ // old-core hash to disambiguate, fall back to the release-notes line.
+ // Under the "yours → core" convention, post-side unmatched blocks land
+ // in `removed_blocks` (would be removed by applying), so that's the
+ // signal we count here.
+ $post_total = count( $post_records );
+ $core_total = count( $core_records );
+ if (
+ 0 === count( $structured['added_blocks'] )
+ && 0 === count( $structured['copy_changes'] )
+ && count( $structured['removed_blocks'] ) >= self::INVERSION_GUARD_THRESHOLD
+ && $post_total >= ( self::INVERSION_GUARD_RATIO * $core_total )
+ ) {
+ $payload = self::fallback_payload( $version_from, $version_to );
+ self::write_cache( $cache_key, $payload );
+ return $payload;
+ }
+
+ $summary_lines = self::to_summary_lines( $structured );
+
+ $payload = array(
+ 'version_from' => $version_from,
+ 'version_to' => $version_to,
+ 'added_blocks' => $structured['added_blocks'],
+ 'removed_blocks' => $structured['removed_blocks'],
+ 'copy_changes' => $structured['copy_changes'],
+ 'structural_changes' => $structured['structural_changes'],
+ 'summary_lines' => $summary_lines,
+ 'is_fallback' => false,
+ 'cache_hit' => false,
+ );
+
+ self::write_cache( $cache_key, $payload );
+
+ return $payload;
+ }
+
+ /**
+ * Drop every cached change-summary transient. Test-only — production
+ * invalidation is automatic via the content-hash key.
+ *
+ * @internal
+ *
+ * @since 10.9.0
+ */
+ public static function reset_cache(): void {
+ global $wpdb;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $wpdb->esc_like( '_transient_wc_email_change_summary_' ) . '%',
+ $wpdb->esc_like( '_transient_timeout_wc_email_change_summary_' ) . '%'
+ )
+ );
+ }
+
+ /**
+ * Override the logger implementation. Intended for tests only.
+ *
+ * @internal
+ *
+ * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+ */
+ public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+ self::$logger = $logger;
+ }
+
+ /**
+ * Empty payload skeleton.
+ *
+ * @return array<string, mixed>
+ */
+ private static function empty_payload(): array {
+ return array(
+ 'version_from' => '',
+ 'version_to' => '',
+ 'added_blocks' => array(),
+ 'removed_blocks' => array(),
+ 'copy_changes' => array(),
+ 'structural_changes' => array(),
+ 'summary_lines' => array(),
+ 'is_fallback' => false,
+ 'cache_hit' => false,
+ );
+ }
+
+ /**
+ * Build the standard fallback payload (release-notes line).
+ *
+ * @param string $version_from Stored version stamp on the post (may be empty).
+ * @param string $version_to Registry-side current version.
+ *
+ * @return array<string, mixed>
+ */
+ private static function fallback_payload( string $version_from, string $version_to ): array {
+ $payload = self::empty_payload();
+ $payload['version_from'] = $version_from;
+ $payload['version_to'] = $version_to;
+ $payload['summary_lines'] = array( __( 'Template updated — see release notes.', 'woocommerce' ) );
+ $payload['is_fallback'] = true;
+ return $payload;
+ }
+
+ /**
+ * Write a payload to the transient cache, pre-stamping `cache_hit` so any
+ * subsequent read is honest about its origin.
+ *
+ * @param string $cache_key Transient key.
+ * @param array<string, mixed> $payload The payload to cache.
+ */
+ private static function write_cache( string $cache_key, array $payload ): void {
+ $to_cache = $payload;
+ $to_cache['cache_hit'] = true;
+ set_transient( $cache_key, $to_cache, self::CACHE_TTL );
+ }
+
+ /**
+ * Resolve the active locale for cache-keying. User locale wins so that
+ * different admins on the same site each see their own translation.
+ */
+ private static function current_locale(): string {
+ $user_locale = function_exists( 'get_user_locale' ) ? (string) get_user_locale() : '';
+ return '' !== $user_locale ? $user_locale : (string) get_locale();
+ }
+
+ /**
+ * Compute the transient key.
+ *
+ * Hash composite is md5-wrapped to keep `option_name` (with the
+ * `_transient_` prefix) well under WP's 191-char limit.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ * @param string $post_hash sha1 of the persisted post content.
+ * @param string $core_hash sha1 of the canonical core render.
+ * @param string $locale Active locale.
+ */
+ private static function cache_key( int $post_id, string $post_hash, string $core_hash, string $locale ): string {
+ return sprintf(
+ 'wc_email_change_summary_%d_%s',
+ $post_id,
+ md5( $post_hash . '|' . $core_hash . '|' . $locale )
+ );
+ }
+
+ /**
+ * DFS-flatten a `parse_blocks()` result into an ordered sequence of node
+ * descriptors (DFS pre-order: parent emitted before its children).
+ * Structural wrapper blocks (`core/group`, `core/columns`, …) are included
+ * in the output — the diff classifier inspects them via
+ * {@see self::STRUCTURAL_BLOCK_NAMES}. Null-name entries (raw HTML wrappers
+ * between blocks) are skipped.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Output of `parse_blocks()`.
+ * @param array<int|string> $path Current index path from root.
+ * @param string|null $parent_name Normalized parent block name, null at root.
+ *
+ * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
+ */
+ private static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
+ $records = array();
+ foreach ( $blocks as $idx => $block ) {
+ if ( ! is_array( $block ) || null === ( $block['blockName'] ?? null ) ) {
+ continue;
+ }
+ $name = self::normalize_block_name( (string) $block['blockName'] );
+ $current_path = array_merge( $path, array( $idx ) );
+
+ $records[] = array(
+ 'path' => $current_path,
+ 'parent_name' => $parent_name,
+ 'name' => $name,
+ 'inner_text' => self::clean_inner_text( (string) ( $block['innerHTML'] ?? '' ) ),
+ );
+
+ if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+ $records = array_merge(
+ $records,
+ self::flatten_blocks( $block['innerBlocks'], $current_path, $name )
+ );
+ }
+ }//end foreach
+ return $records;
+ }
+
+ /**
+ * Apply the namespace-alias map.
+ *
+ * @param string $name Raw block name from `parse_blocks()`.
+ */
+ private static function normalize_block_name( string $name ): string {
+ return self::BLOCK_NAME_ALIASES[ $name ] ?? $name;
+ }
+
+ /**
+ * Strip tags and collapse whitespace. Used as the basis for copy-change
+ * comparison — semantic content only, no markup-shape noise.
+ *
+ * @param string $html Raw `innerHTML` string from a parsed block.
+ */
+ private static function clean_inner_text( string $html ): string {
+ $stripped = wp_strip_all_tags( $html );
+ $collapsed = preg_replace( '/\s+/', ' ', (string) $stripped );
+ return trim( (string) $collapsed );
+ }
+
+ /**
+ * Diff two flattened record sequences.
+ *
+ * Each `added_blocks` / `removed_blocks` / `copy_changes` / `structural_changes`
+ * entry carries a `path` field — the index path through the parsed block
+ * tree on the side where the relevant block exists:
+ *
+ * - `added_blocks[].path` — core-side path (where it would land if applied).
+ * - `removed_blocks[].path` — post-side path (where it currently sits).
+ * - `copy_changes[].path` — post-side path (the merchant's renderable surface).
+ * - `structural_changes[].path` — post-side for matched-pair moves; the
+ * unmatched side for wrapper additions/removals;
+ * omitted for `kind: 'reorder'` entries (no single block).
+ *
+ * RSM-143's selective-merge UI uses `path` to map per-block "Keep yours /
+ * Use core" choices back to specific blocks during merge.
+ *
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side.
+ *
+ * @return array{added_blocks:array<int, array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>}
+ */
+ private static function diff_records( array $core_records, array $post_records ): array {
+ $core_names = array_map( static fn( array $r ): string => $r['name'], $core_records );
+
+ $matches = self::lcs_matches( $core_records, $post_records );
+
+ $matched_core = array();
+ $matched_post = array();
+ foreach ( $matches as $pair ) {
+ $matched_core[ $pair[0] ] = true;
+ $matched_post[ $pair[1] ] = true;
+ }
+
+ $added_blocks = array();
+ $removed_blocks = array();
+ $copy_changes = array();
+ $structural_changes = array();
+
+ // `added_blocks` / `removed_blocks` follow the "yours → core" convention:
+ // `added_blocks` = blocks the merchant would gain by applying the update
+ // (in core, not in post). `removed_blocks` = blocks the merchant would
+ // lose by applying the update (in post, not in core). Same direction as
+ // `before` (yours) / `after` (core) on copy_changes.
+ //
+ // Pass order: matched pairs first, then unmatched. The matched-pair
+ // pass collects parent-name pairs whose mismatch will already produce
+ // a "Moved %1$s into %2$s" entry, so the unmatched-pass can suppress
+ // the redundant "Added/Removed %s wrapper" entry that would otherwise
+ // describe the same physical edit twice.
+
+ // Pass 1: classify matched pairs.
+ $core_name_counts = array_count_values( $core_names );
+ $occurrence_index = array();
+ $matched_core_parents = array();
+ $matched_post_parents = array();
+ foreach ( $matches as $pair ) {
+ $core = $core_records[ $pair[0] ];
+ $post_r = $post_records[ $pair[1] ];
+ $name = $core['name'];
+ $label = self::block_label( $name );
+
+ $occurrence_index[ $name ] = ( $occurrence_index[ $name ] ?? 0 ) + 1;
+ $total = (int) ( $core_name_counts[ $name ] ?? 1 );
+
+ if ( $core['parent_name'] !== $post_r['parent_name'] ) {
+ // Destination is core's parent (where the block would land after
+ // applying the update), not post's (where it currently sits).
+ $structural_changes[] = array(
+ 'kind' => 'nest',
+ 'description' => sprintf(
+ /* translators: 1: block name; 2: parent block name */
+ __( 'Moved %1$s into %2$s', 'woocommerce' ),
+ $label,
+ null === $core['parent_name'] ? __( 'top level', 'woocommerce' ) : self::block_label( $core['parent_name'] )
+ ),
+ 'path' => $post_r['path'],
+ );
+ if ( null !== $post_r['parent_name'] ) {
+ $matched_post_parents[ $post_r['parent_name'] ] = true;
+ }
+ if ( null !== $core['parent_name'] ) {
+ $matched_core_parents[ $core['parent_name'] ] = true;
+ }
+ }
+
+ if ( $core['inner_text'] !== $post_r['inner_text'] ) {
+ // `before` = merchant's current text (what they have now), `after` = canonical
+ // core text (what they would get if they applied the update). Matches the
+ // design's "yours" → "core" diff convention.
+ $copy_changes[] = array(
+ 'block' => $label,
+ 'before' => self::truncate_text( $post_r['inner_text'] ),
+ 'after' => self::truncate_text( $core['inner_text'] ),
+ 'occurrence' => $occurrence_index[ $name ],
+ 'total' => $total,
+ 'path' => $post_r['path'],
+ );
+ }
+ }//end foreach
+
+ // Pass 2: classify unmatched core. Skip wrapper entry if a matched
+ // pair already names this wrapper as its core-side parent — that
+ // matched pair's "Moved into" entry covers the same physical edit.
+ foreach ( $core_records as $i => $rec ) {
+ if ( isset( $matched_core[ $i ] ) ) {
+ continue;
+ }
+ if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
+ if ( isset( $matched_core_parents[ $rec['name'] ] ) ) {
+ continue;
+ }
+ $structural_changes[] = array(
+ 'kind' => 'nest',
+ 'description' => sprintf(
+ /* translators: %s: block name */
+ __( 'Added %s wrapper', 'woocommerce' ),
+ self::block_label( $rec['name'] )
+ ),
+ 'path' => $rec['path'],
+ );
+ continue;
+ }
+ $added_blocks[] = array(
+ 'name' => $rec['name'],
+ 'label' => self::block_label( $rec['name'] ),
+ 'path' => $rec['path'],
+ );
+ }//end foreach
+
+ // Pass 3: classify unmatched post, with the same wrapper suppression.
+ foreach ( $post_records as $i => $rec ) {
+ if ( isset( $matched_post[ $i ] ) ) {
+ continue;
+ }
+ if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
+ if ( isset( $matched_post_parents[ $rec['name'] ] ) ) {
+ continue;
+ }
+ $structural_changes[] = array(
+ 'kind' => 'nest',
+ 'description' => sprintf(
+ /* translators: %s: block name */
+ __( 'Removed %s wrapper', 'woocommerce' ),
+ self::block_label( $rec['name'] )
+ ),
+ 'path' => $rec['path'],
+ );
+ continue;
+ }
+ $removed_blocks[] = array(
+ 'name' => $rec['name'],
+ 'label' => self::block_label( $rec['name'] ),
+ 'path' => $rec['path'],
+ );
+ }//end foreach
+
+ // Reorder pass: pair like-named entries between added and removed
+ // and reclassify them as a `reorder` structural change. LCS only
+ // matches in-order, so an actual reorder of matched blocks lands here
+ // as add+remove pairs. Reorder entries omit `path` because they
+ // describe a structural fact, not a single block.
+ //
+ // Pairing keys on the normalized block name (e.g. `core/heading`),
+ // not the humanized label. Two distinct namespaces — say
+ // `vendor-a/header` and `vendor-b/header` — both produce the label
+ // `Header` after `block_label()` strips the namespace; pairing on
+ // label would falsely emit a single `Reordered Header` entry instead
+ // of one add + one remove.
+ $added_name_indices = array();
+ $removed_name_indices = array();
+ foreach ( $added_blocks as $i => $entry ) {
+ $added_name_indices[ (string) $entry['name'] ][] = $i;
+ }
+ foreach ( $removed_blocks as $i => $entry ) {
+ $removed_name_indices[ (string) $entry['name'] ][] = $i;
+ }
+
+ $dropped_added = array();
+ $dropped_removed = array();
+ foreach ( $added_name_indices as $name => $a_indices ) {
+ $r_indices = $removed_name_indices[ $name ] ?? array();
+ $pairs = (int) min( count( $a_indices ), count( $r_indices ) );
+ if ( 0 === $pairs ) {
+ continue;
+ }
+ $label = self::block_label( (string) $name );
+ for ( $i = 0; $i < $pairs; $i++ ) {
+ $structural_changes[] = array(
+ 'kind' => 'reorder',
+ 'description' => sprintf(
+ /* translators: %s: block name */
+ __( 'Reordered %s', 'woocommerce' ),
+ $label
+ ),
+ );
+
+ $dropped_added[ $a_indices[ $i ] ] = true;
+ $dropped_removed[ $r_indices[ $i ] ] = true;
+ }
+ }//end foreach
+ $added_blocks = self::reject_indices( $added_blocks, $dropped_added );
+ $removed_blocks = self::reject_indices( $removed_blocks, $dropped_removed );
+
+ return array(
+ 'added_blocks' => $added_blocks,
+ 'removed_blocks' => $removed_blocks,
+ 'copy_changes' => $copy_changes,
+ 'structural_changes' => $structural_changes,
+ );
+ }
+
+ /**
+ * Drop entries at the given indices and return a re-indexed list. Used by
+ * the reorder pass to remove paired entries from added/removed without
+ * losing the rich shape of the survivors.
+ *
+ * @param array<int, array<string, mixed>> $entries Source list.
+ * @param array<int, true> $drop Indices to drop.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ private static function reject_indices( array $entries, array $drop ): array {
+ $out = array();
+ foreach ( $entries as $i => $entry ) {
+ if ( ! isset( $drop[ $i ] ) ) {
+ $out[] = $entry;
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Bonus weight per name match used to tiebreak by text similarity. Must be
+ * small enough that cardinality always dominates: with sequences up to ~100
+ * blocks long, the total accumulated bonus is bounded by 0.1 — well under
+ * the +1.0 contribution of an extra name match.
+ */
+ private const LCS_SIMILARITY_BONUS = 0.001;
+
+ /**
+ * Compute LCS over two flattened record sequences with text similarity as
+ * a tiebreaker. Returns matched pairs as `(core_index, post_index)` tuples
+ * in increasing order on both axes.
+ *
+ * Cardinality (number of name matches) is the primary criterion. When two
+ * alignments tie on cardinality — common on uniform block-name runs like
+ * `paragraph × N` — the diagonal score adds a tiny bonus proportional to
+ * the Jaccard word similarity of the two records' inner text. The bonus is
+ * bounded so it can never trade a name match for a similarity gain. Net
+ * effect: when the merchant edits an existing paragraph, the LCS pairs
+ * their edited version with core's original (high word overlap) instead of
+ * with an unrelated paragraph that happens to be in the right position.
+ *
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $a Core records.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $b Post records.
+ *
+ * @return array<int, array{0:int, 1:int}>
+ */
+ private static function lcs_matches( array $a, array $b ): array {
+ $n = count( $a );
+ $m = count( $b );
+ if ( 0 === $n || 0 === $m ) {
+ return array();
+ }
+
+ $dp = array_fill( 0, $n + 1, array_fill( 0, $m + 1, 0.0 ) );
+ for ( $i = 1; $i <= $n; $i++ ) {
+ for ( $j = 1; $j <= $m; $j++ ) {
+ $up = $dp[ $i - 1 ][ $j ];
+ $left = $dp[ $i ][ $j - 1 ];
+ if ( $a[ $i - 1 ]['name'] === $b[ $j - 1 ]['name'] ) {
+ $bonus = self::LCS_SIMILARITY_BONUS * self::similarity_score(
+ $a[ $i - 1 ]['inner_text'],
+ $b[ $j - 1 ]['inner_text']
+ );
+ // Must compare against `up` and `left` even when names
+ // match: the bonus on the diagonal can be smaller than
+ // bonuses already accumulated in `up` / `left`. Taking the
+ // diagonal unconditionally would discard a higher-scoring
+ // alignment found via a different path. Cardinality is
+ // preserved because the max bonus per match is far below
+ // 1.0, so the diagonal still wins whenever it adds a new
+ // name match.
+ $diagonal = $dp[ $i - 1 ][ $j - 1 ] + 1.0 + $bonus;
+ $dp[ $i ][ $j ] = max( $diagonal, $up, $left );
+ } else {
+ $dp[ $i ][ $j ] = max( $up, $left );
+ }
+ }//end for
+ }//end for
+
+ $pairs = array();
+ $i = $n;
+ $j = $m;
+ while ( $i > 0 && $j > 0 ) {
+ if ( $a[ $i - 1 ]['name'] === $b[ $j - 1 ]['name'] ) {
+ $bonus = self::LCS_SIMILARITY_BONUS * self::similarity_score(
+ $a[ $i - 1 ]['inner_text'],
+ $b[ $j - 1 ]['inner_text']
+ );
+ $diagonal_score = $dp[ $i - 1 ][ $j - 1 ] + 1.0 + $bonus;
+ if ( abs( $dp[ $i ][ $j ] - $diagonal_score ) < 1e-9 ) {
+ $pairs[] = array( $i - 1, $j - 1 );
+ --$i;
+ --$j;
+ continue;
+ }
+ }
+ if ( $dp[ $i - 1 ][ $j ] >= $dp[ $i ][ $j - 1 ] ) {
+ --$i;
+ } else {
+ --$j;
+ }
+ }
+
+ return array_reverse( $pairs );
+ }
+
+ /**
+ * Jaccard word-set similarity in [0.0, 1.0]. Used purely as an LCS
+ * tiebreaker, so robustness is more important than linguistic accuracy:
+ * lowercase + split on whitespace + intersect-over-union of the resulting
+ * word sets. Two empty strings score 1.0 (treated as identical).
+ *
+ * Lowercasing goes through `wc_strtolower()` (mb-aware with an ASCII
+ * fallback), not `strtolower()` — the latter is ASCII-only and would
+ * leave accented / Cyrillic / Greek characters uppercase, killing
+ * word-overlap matches on translated email templates.
+ *
+ * @param string $a First text.
+ * @param string $b Second text.
+ */
+ private static function similarity_score( string $a, string $b ): float {
+ $a = trim( $a );
+ $b = trim( $b );
+ if ( '' === $a && '' === $b ) {
+ return 1.0;
+ }
+ if ( '' === $a || '' === $b ) {
+ return 0.0;
+ }
+
+ $split_a = preg_split( '/\s+/', wc_strtolower( $a ), -1, PREG_SPLIT_NO_EMPTY );
+ $split_b = preg_split( '/\s+/', wc_strtolower( $b ), -1, PREG_SPLIT_NO_EMPTY );
+ $words_a = array_unique( false === $split_a ? array() : $split_a );
+ $words_b = array_unique( false === $split_b ? array() : $split_b );
+ if ( empty( $words_a ) && empty( $words_b ) ) {
+ return 1.0;
+ }
+
+ $intersect = count( array_intersect( $words_a, $words_b ) );
+ $union = count( array_unique( array_merge( $words_a, $words_b ) ) );
+ return $union > 0 ? $intersect / $union : 0.0;
+ }
+
+ /**
+ * Convert a normalized block name into a human-readable label. Used for
+ * both structured payload entries and the localized catalog.
+ *
+ * `core/heading` → `Heading`; `woocommerce/email-content` → `Email content`.
+ *
+ * @param string $normalized_name Normalized block name.
+ */
+ private static function block_label( string $normalized_name ): string {
+ $bare = preg_replace( '#^[a-z0-9\-]+/#', '', $normalized_name );
+ $bare = (string) $bare;
+ $bare = str_replace( array( '-', '_' ), ' ', $bare );
+ $bare = trim( $bare );
+ if ( '' === $bare ) {
+ return $normalized_name;
+ }
+ return ucfirst( $bare );
+ }
+
+ /**
+ * UTF-8-safe truncation to {@see self::COPY_TRUNCATE_CHARS}.
+ *
+ * @param string $text Cleaned inner-text candidate.
+ */
+ private static function truncate_text( string $text ): string {
+ $limit = self::COPY_TRUNCATE_CHARS;
+ if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
+ if ( mb_strlen( $text ) <= $limit ) {
+ return $text;
+ }
+ return rtrim( mb_substr( $text, 0, $limit ) ) . '…';
+ }
+ if ( strlen( $text ) <= $limit ) {
+ return $text;
+ }
+ return rtrim( substr( $text, 0, $limit ) ) . '…';
+ }
+
+ /**
+ * Render the structured payload into pre-localized one-liners, using a
+ * fixed string catalog. The "N of M" position-and-total disambiguator
+ * (e.g. "Updated wording in Paragraph 1 of 2") only fires when a block
+ * label appears more than once on the matched side.
+ *
+ * @param array{added_blocks:array<int, array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>} $structured Diff output.
+ * @return string[]
+ */
+ private static function to_summary_lines( array $structured ): array {
+ $lines = array();
+
+ // Singular form drops the indefinite article ("Added Image block" rather
+ // than "Added a Image block") because English a/an depends on phonetics
+ // the catalog can't infer at format time, and many target locales have no
+ // equivalent article at all. Plural already reads "Added 3 Image blocks"
+ // without an article, so dropping it in singular keeps both forms
+ // stylistically consistent.
+ $added_labels = array_map( static fn( array $e ): string => (string) ( $e['label'] ?? '' ), $structured['added_blocks'] );
+ $added_counts = array_count_values( array_filter( $added_labels, static fn( string $l ): bool => '' !== $l ) );
+ foreach ( $added_counts as $label => $count ) {
+ $count = (int) $count;
+ if ( 1 === $count ) {
+ $lines[] = sprintf(
+ /* translators: %s: block name */
+ __( 'Added %s block', 'woocommerce' ),
+ (string) $label
+ );
+ } else {
+ $lines[] = sprintf(
+ /* translators: 1: number of blocks added; 2: block name */
+ __( 'Added %1$d %2$s blocks', 'woocommerce' ),
+ $count,
+ (string) $label
+ );
+ }
+ }
+
+ $removed_labels = array_map( static fn( array $e ): string => (string) ( $e['label'] ?? '' ), $structured['removed_blocks'] );
+ $removed_counts = array_count_values( array_filter( $removed_labels, static fn( string $l ): bool => '' !== $l ) );
+ foreach ( $removed_counts as $label => $count ) {
+ $count = (int) $count;
+ if ( 1 === $count ) {
+ $lines[] = sprintf(
+ /* translators: %s: block name */
+ __( 'Removed %s block', 'woocommerce' ),
+ (string) $label
+ );
+ } else {
+ $lines[] = sprintf(
+ /* translators: 1: number of blocks removed; 2: block name */
+ __( 'Removed %1$d %2$s blocks', 'woocommerce' ),
+ $count,
+ (string) $label
+ );
+ }
+ }
+
+ foreach ( $structured['copy_changes'] as $change ) {
+ $label = (string) ( $change['block'] ?? '' );
+ $occurrence = (int) ( $change['occurrence'] ?? 1 );
+ $total = (int) ( $change['total'] ?? 1 );
+
+ if ( $total > 1 ) {
+ $lines[] = sprintf(
+ /* translators: 1: block name (e.g. "Paragraph"); 2: position of the edited block (e.g. 1); 3: total blocks of that type in the template (e.g. 2). Reads as "Updated wording in Paragraph 1 of 2". */
+ __( 'Updated wording in %1$s %2$d of %3$d', 'woocommerce' ),
+ $label,
+ $occurrence,
+ $total
+ );
+ } else {
+ $lines[] = sprintf(
+ /* translators: %s: block name */
+ __( 'Updated wording in %s', 'woocommerce' ),
+ $label
+ );
+ }
+ }//end foreach
+
+ foreach ( $structured['structural_changes'] as $change ) {
+ $desc = (string) ( $change['description'] ?? '' );
+ if ( '' !== $desc ) {
+ $lines[] = $desc;
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Return the logger instance, lazily creating it the first time.
+ */
+ private static function get_logger(): Email_Editor_Logger_Interface {
+ if ( null === self::$logger ) {
+ self::$logger = new Logger( wc_get_logger() );
+ }
+ return self::$logger;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index 691fe2debcb..081ded321ea 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
use Automattic\WooCommerce\Internal\EmailEditor\EmailApiController;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
@@ -589,6 +590,86 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertSame( 500, $result->get_error_data()['status'] );
}
+ /**
+ * @testdox Should return a structured change-summary payload for a divergent woo_email post.
+ */
+ public function test_change_summary_route_returns_structured_payload(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ // Diverge the post from the canonical render so the summary has something to say.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => "<!-- wp:paragraph -->\n<p>Merchant-edited paragraph.</p>\n<!-- /wp:paragraph -->",
+ )
+ );
+
+ $request = new \WP_REST_Request( 'GET', '/woocommerce-email-editor/v1/emails/' . $post_id . '/change-summary' );
+ $request->set_param( 'id', $post_id );
+
+ $result = $this->email_api_controller->get_change_summary_response( $request );
+
+ $this->assertInstanceOf( \WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+
+ $data = $result->get_data();
+ $this->assertIsArray( $data );
+
+ foreach (
+ array(
+ 'version_from',
+ 'version_to',
+ 'added_blocks',
+ 'removed_blocks',
+ 'copy_changes',
+ 'structural_changes',
+ 'summary_lines',
+ 'is_fallback',
+ 'cache_hit',
+ ) as $key
+ ) {
+ $this->assertArrayHasKey( $key, $data, "Response payload must include `$key`." );
+ }
+
+ $this->assertNotEmpty( $data['version_to'], 'version_to must be populated from the registry.' );
+ $this->assertIsArray( $data['summary_lines'] );
+ }
+
+ /**
+ * @testdox Should return 404 from the change-summary route when no email matches the post.
+ */
+ public function test_change_summary_route_returns_404_for_unknown_post(): void {
+ $unassociated_post = $this->factory()->post->create_and_get(
+ array(
+ 'post_title' => 'Unknown Email',
+ 'post_name' => 'unknown_email_for_change_summary',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $request = new \WP_REST_Request( 'GET', '/woocommerce-email-editor/v1/emails/' . $unassociated_post->ID . '/change-summary' );
+ $request->set_param( 'id', $unassociated_post->ID );
+
+ $result = $this->email_api_controller->get_change_summary_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_not_found', $result->get_error_code() );
+ $this->assertSame( 404, $result->get_error_data()['status'] );
+ }
+
/**
* @testdox Should register a POST /reset route alongside the existing default-content route.
*/
@@ -609,6 +690,32 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( 'POST', $methods, 'Reset endpoint must accept POST.' );
}
+ /**
+ * @testdox Should register a GET /change-summary route alongside the existing default-content route.
+ */
+ public function test_register_routes_registers_change_summary_endpoint(): void {
+ // `register_rest_route()` warns when called outside `rest_api_init`. The
+ // reset sibling test only avoids the warning because it's the first
+ // caller of `rest_get_server()` in the suite, which lazily fires the
+ // action; this test runs after that, so we opt into the warning.
+ $this->setExpectedIncorrectUsage( 'register_rest_route' );
+
+ $rest_server = rest_get_server();
+ $this->email_api_controller->register_routes();
+
+ $routes = $rest_server->get_routes();
+ $this->assertArrayHasKey( '/woocommerce-email-editor/v1/emails/(?P<id>\d+)/change-summary', $routes );
+
+ $change_summary_route_handlers = $routes['/woocommerce-email-editor/v1/emails/(?P<id>\d+)/change-summary'];
+ $methods = array();
+ foreach ( $change_summary_route_handlers as $handler ) {
+ foreach ( array_keys( $handler['methods'] ) as $method ) {
+ $methods[ $method ] = true;
+ }
+ }
+ $this->assertArrayHasKey( 'GET', $methods, 'Change-summary endpoint must accept GET.' );
+ }
+
/**
* Helper: resolve a WC_Email instance by email type ID.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
new file mode 100644
index 00000000000..72908eb928d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
@@ -0,0 +1,634 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WCEmailTemplateChangeSummary class.
+ */
+class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory.
+ *
+ * @var string
+ */
+ private string $fixtures_base;
+
+ /**
+ * Keys injected into \WC_Emails::$emails during the current test.
+ *
+ * @var string[]
+ */
+ private array $injected_email_keys = array();
+
+ /**
+ * Transactional email post manager singleton.
+ *
+ * @var WCTransactionalEmailPostsManager
+ */
+ private WCTransactionalEmailPostsManager $posts_manager;
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+ $this->fixtures_base = __DIR__ . '/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+ // Singleton caches survive test transaction rollback and would otherwise
+ // leak stale post_id <-> email_type mappings into subsequent tests.
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_filters( 'woocommerce_email_content_post_data' );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+ WCEmailTemplateChangeSummary::set_logger( null );
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Mixed-changes happy path: covers added blocks, removed blocks, and copy
+ * changes against a single fixture in one assertion pass.
+ *
+ * @testdox Should return a structured summary covering added, removed, and copy-changed blocks in a single pass.
+ */
+ public function test_summarize_returns_structured_payload_for_mixed_changes(): void {
+ $email_id = 'change_summary_mixed';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Hello there.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Original line.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Goodbye block.</p>\n<!-- /wp:paragraph -->";
+
+ // Merchant edited the third paragraph and added a custom image; removed the goodbye paragraph.
+ $post_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Hello there.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Edited line.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:image -->\n<figure class=\"wp-block-image\"><img src=\"x\"/></figure>\n<!-- /wp:image -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertArrayHasKey( 'is_fallback', $result );
+ $this->assertFalse( $result['is_fallback'] );
+
+ // Yours → core convention: applying would REMOVE the merchant's image
+ // (it's in their post, not in core) and ADD the goodbye paragraph
+ // (it's in core, not in their post). Each entry carries a `path`.
+ $this->assertArrayHasKey( 'removed_blocks', $result );
+ $this->assertContains( 'Image', array_column( $result['removed_blocks'], 'label' ) );
+
+ $this->assertArrayHasKey( 'added_blocks', $result );
+ $this->assertContains( 'Paragraph', array_column( $result['added_blocks'], 'label' ) );
+
+ // Path field present on every rich entry.
+ foreach ( $result['removed_blocks'] as $entry ) {
+ $this->assertArrayHasKey( 'path', $entry );
+ $this->assertIsArray( $entry['path'] );
+ }
+ foreach ( $result['added_blocks'] as $entry ) {
+ $this->assertArrayHasKey( 'path', $entry );
+ $this->assertIsArray( $entry['path'] );
+ }
+
+ $this->assertArrayHasKey( 'copy_changes', $result );
+ $this->assertCount( 1, $result['copy_changes'] );
+ $this->assertSame( 'Paragraph', $result['copy_changes'][0]['block'] );
+ // `before` = merchant's current post; `after` = canonical core text.
+ $this->assertSame( 'Edited line.', $result['copy_changes'][0]['before'] );
+ $this->assertSame( 'Original line.', $result['copy_changes'][0]['after'] );
+ $this->assertArrayHasKey( 'path', $result['copy_changes'][0] );
+ $this->assertIsArray( $result['copy_changes'][0]['path'] );
+
+ $this->assertArrayHasKey( 'summary_lines', $result );
+ $this->assertNotEmpty( $result['summary_lines'] );
+ $this->assertContains( 'Removed Image block', $result['summary_lines'] );
+ }
+
+ /**
+ * Namespace-alias normalization: post uses `woo/email-content` while core
+ * uses `woocommerce/email-content`. Should match as the same block, not
+ * surface as add+remove.
+ *
+ * @testdox Should match `woo/email-content` and `woocommerce/email-content` as the same block via namespace-alias normalization.
+ */
+ public function test_summarize_normalizes_namespace_aliased_blocks(): void {
+ $email_id = 'change_summary_alias';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:woocommerce/email-content -->\n<div class=\"wp-block-woocommerce-email-content\">##WOO_CONTENT##</div>\n<!-- /wp:woocommerce/email-content -->";
+
+ $post_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:woo/email-content -->\n<div class=\"wp-block-woo-email-content\">##WOO_CONTENT##</div>\n<!-- /wp:woo/email-content -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $this->assertEmpty( $result['added_blocks'], 'Aliased block must not appear in added_blocks.' );
+ $this->assertEmpty( $result['removed_blocks'], 'Aliased block must not appear in removed_blocks.' );
+ }
+
+ /**
+ * Depth-asymmetric input: merchant wraps two paragraphs in a Group while
+ * core stays flat. The flatten-then-LCS pipeline must surface this as a
+ * structural `nest` change, not as a paragraph add/remove cascade.
+ *
+ * @testdox Should surface depth asymmetry as a structural `nest` change rather than a paragraph add/remove cascade.
+ */
+ public function test_summarize_handles_depth_asymmetry_via_dfs_flatten(): void {
+ $email_id = 'change_summary_nest';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->";
+
+ $post_content = "<!-- wp:group -->\n<div class=\"wp-block-group\">"
+ . "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->"
+ . "</div>\n<!-- /wp:group -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $this->assertNotContains( 'Paragraph', array_column( $result['added_blocks'], 'label' ) );
+ $this->assertNotContains( 'Paragraph', array_column( $result['removed_blocks'], 'label' ) );
+
+ $this->assertNotEmpty( $result['structural_changes'] );
+ $kinds = array_map( static fn( $c ): string => (string) ( $c['kind'] ?? '' ), $result['structural_changes'] );
+ $this->assertContains( 'nest', $kinds );
+
+ // Wrapper-suppression rule: every matched paragraph emits a "Moved
+ // Paragraph into top level" entry; the bare "Removed Group wrapper"
+ // entry is suppressed because the matched-pair entries already cover
+ // the same physical edit.
+ $descriptions = array_map( static fn( $c ): string => (string) ( $c['description'] ?? '' ), $result['structural_changes'] );
+ foreach ( $descriptions as $description ) {
+ $this->assertStringNotContainsString( 'Group wrapper', $description, 'Group wrapper entry must be suppressed when matched pairs cover the move.' );
+ }
+ }
+
+ /**
+ * Reorder pairing must group by normalized block name, not by humanized
+ * label. Two distinct namespaces (e.g. `vendor-a/header` and
+ * `vendor-b/header`) both produce the label `Header`; pairing on label
+ * would falsely emit a single `Reordered Header` entry instead of one
+ * add + one remove.
+ *
+ * Fixture: core has a single `vendor-a/header`. Post has a single
+ * `vendor-b/header`. Different blocks under the same humanized label.
+ * Expected: one add + one remove, no reorder entry.
+ *
+ * @testdox Should pair reorder candidates by normalized block name, not by humanized label.
+ */
+ public function test_summarize_reorder_pairs_by_normalized_name_not_humanized_label(): void {
+ $email_id = 'change_summary_namespace_collision';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = '<!-- wp:vendor-a/header --><div class="wp-block-vendor-a-header">Vendor A header.</div><!-- /wp:vendor-a/header -->';
+ $post_content = '<!-- wp:vendor-b/header --><div class="wp-block-vendor-b-header">Vendor B header.</div><!-- /wp:vendor-b/header -->';
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $this->assertCount( 1, $result['added_blocks'], 'vendor-a/header is in core but not in post — should land in added_blocks.' );
+ $this->assertCount( 1, $result['removed_blocks'], 'vendor-b/header is in post but not in core — should land in removed_blocks.' );
+
+ // Each entry should expose the raw normalized name alongside the label.
+ $this->assertSame( 'vendor-a/header', $result['added_blocks'][0]['name'] );
+ $this->assertSame( 'Header', $result['added_blocks'][0]['label'] );
+ $this->assertSame( 'vendor-b/header', $result['removed_blocks'][0]['name'] );
+ $this->assertSame( 'Header', $result['removed_blocks'][0]['label'] );
+
+ // No `reorder` structural change must be emitted — the two blocks
+ // share a label but not a name.
+ $kinds = array_map( static fn( $c ): string => (string) ( $c['kind'] ?? '' ), $result['structural_changes'] );
+ $this->assertNotContains( 'reorder', $kinds, 'Different namespaces sharing a label must NOT collapse into a reorder pairing.' );
+ }
+
+ /**
+ * Wrapper suppression must not over-suppress: when the unmatched
+ * structural block has no matched pair pointing at it as a parent, the
+ * wrapper entry is the only place the change shows up and must be kept.
+ *
+ * Fixture: post wraps a Heading (different name from core's Paragraph) in
+ * a Group. LCS finds no matches. Group has no matched-pair child with
+ * parent=Group, so its "Removed Group wrapper" entry must remain.
+ *
+ * @testdox Should keep the wrapper structural entry when no matched pair points at it as a parent.
+ */
+ public function test_summarize_keeps_wrapper_entry_when_no_matched_pair_covers_it(): void {
+ $email_id = 'change_summary_wrapper_keep';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core paragraph.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:group -->\n<div class=\"wp-block-group\">"
+ . "<!-- wp:heading -->\n<h2>Merchant heading.</h2>\n<!-- /wp:heading -->"
+ . "</div>\n<!-- /wp:group -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $descriptions = array_map( static fn( $c ): string => (string) ( $c['description'] ?? '' ), $result['structural_changes'] );
+ $this->assertContains(
+ 'Removed Group wrapper',
+ $descriptions,
+ 'Group wrapper entry must NOT be suppressed when no matched pair points at Group as a parent.'
+ );
+ }
+
+ /**
+ * LCS resists the cascade noise that a positional walk would produce on
+ * uniform paragraph runs: insert a single paragraph at index 1 in a long
+ * run; only one block should be reported, not the whole tail.
+ *
+ * @testdox Should align via LCS so a single inserted block in a uniform run does not cascade into the entire tail.
+ */
+ public function test_summarize_lcs_alignment_resists_paragraph_run_cascade(): void {
+ $email_id = 'change_summary_cascade';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Three.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Four.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Five.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Six.</p>\n<!-- /wp:paragraph -->";
+
+ // Merchant inserted a new heading after the first paragraph.
+ $post_content = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:heading -->\n<h2>Inserted heading</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Three.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Four.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Five.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Six.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ // Yours → core convention: the inserted heading is in the post but not
+ // in core, so applying would REMOVE it.
+ $this->assertCount( 1, $result['removed_blocks'], 'Only the inserted heading should be reported.' );
+ $this->assertSame( 'Heading', $result['removed_blocks'][0]['label'] );
+ $this->assertIsArray( $result['removed_blocks'][0]['path'] );
+ $this->assertEmpty( $result['added_blocks'] );
+ $this->assertEmpty( $result['copy_changes'], 'No spurious copy_changes should cascade through indices 2..6.' );
+ }
+
+ /**
+ * Similarity-scored LCS picks the text-similar pairing when names tie.
+ *
+ * Without similarity scoring, name-only LCS over a uniform paragraph run
+ * can pair the merchant's edited paragraph with an unrelated one in the
+ * post, attributing the wrong "before" / "after" to the copy_change. With
+ * the bonus, the LCS prefers the pairing where matched pairs share the
+ * most words.
+ *
+ * @testdox Should prefer the text-similar pairing in uniform block runs so copy_changes carry the right before/after.
+ */
+ public function test_summarize_prefers_text_similar_pairing_in_uniform_block_runs(): void {
+ $email_id = 'change_summary_similarity';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>You have received a new order from a customer.</p>\n<!-- /wp:paragraph -->";
+
+ // Merchant kept core's paragraph (with a small "Nice." prefix) and
+ // added two unrelated paragraphs after it. Without similarity scoring,
+ // LCS could pair the matched core Paragraph with any of the three
+ // post Paragraphs by name alone — typically the last one.
+ $post_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Nice. You have received a new order from a customer.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Random promotional text.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Some other unrelated note.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $this->assertCount( 1, $result['copy_changes'], 'Exactly one paragraph should be matched and flagged as a copy_change.' );
+ // The matched paragraph must be the high-similarity pairing — the
+ // merchant's "Nice. You have received..." against core's "You have
+ // received...". Bare position-based LCS would have paired core's
+ // paragraph with the third post paragraph and put "Some other
+ // unrelated note." in `before`.
+ $this->assertSame( 'Nice. You have received a new order from a customer.', $result['copy_changes'][0]['before'] );
+ $this->assertSame( 'You have received a new order from a customer.', $result['copy_changes'][0]['after'] );
+ // The two truly unrelated paragraphs should be the unmatched ones.
+ $this->assertCount( 2, $result['removed_blocks'] );
+ }
+
+ /**
+ * Summary-inversion guard: a heavily one-sided expansion (5+ added, 0
+ * removed, 0 copy, ≥1.5x core size) trips the guard and falls back.
+ *
+ * @testdox Should fall back to the release-notes summary when the inversion guard trips on a heavily one-sided expansion.
+ */
+ public function test_summarize_falls_back_on_summary_inversion(): void {
+ $email_id = 'change_summary_inversion';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Hello.</p>\n<!-- /wp:paragraph -->";
+
+ // Merchant added 6 unrelated blocks; nothing on the core side was edited or removed.
+ $post_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Hello.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+ . "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+ . "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+ . "<!-- wp:gallery --><figure></figure><!-- /wp:gallery -->\n\n"
+ . "<!-- wp:list --><ul></ul><!-- /wp:list -->\n\n"
+ . '<!-- wp:separator --><hr/><!-- /wp:separator -->';
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertTrue( $result['is_fallback'] );
+ $this->assertSame(
+ array( __( 'Template updated — see release notes.', 'woocommerce' ) ),
+ $result['summary_lines']
+ );
+ $this->assertEmpty( $result['added_blocks'] );
+ $this->assertEmpty( $result['copy_changes'] );
+ }
+
+ /**
+ * Generic fallback: post outside the registry returns the release-notes
+ * line and empty structured arrays.
+ *
+ * @testdox Should fall back to the release-notes summary for posts outside the sync registry.
+ */
+ public function test_summarize_falls_back_when_post_is_not_in_registry(): void {
+ // No fixture email registered for this email_id, so the registry gate fails.
+ $post_id = $this->create_woo_email_post(
+ 'change_summary_unregistered',
+ "<!-- wp:paragraph -->\n<p>Anything.</p>\n<!-- /wp:paragraph -->"
+ );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertTrue( $result['is_fallback'] );
+ $this->assertSame(
+ array( __( 'Template updated — see release notes.', 'woocommerce' ) ),
+ $result['summary_lines']
+ );
+ $this->assertEmpty( $result['added_blocks'] );
+ $this->assertEmpty( $result['removed_blocks'] );
+ $this->assertEmpty( $result['copy_changes'] );
+ $this->assertEmpty( $result['structural_changes'] );
+ }
+
+ /**
+ * Identical post and core content returns a successful zero-result: empty
+ * structured arrays, empty summary_lines, is_fallback: false.
+ *
+ * `is_fallback` is reserved for "diff could not be produced." A no-op is a
+ * successful result — consumers detect it by the absence of deltas and
+ * render any "you're up to date" copy themselves.
+ *
+ * @testdox Should return an empty, non-fallback payload when post content equals the canonical core render.
+ */
+ public function test_summarize_returns_empty_payload_when_post_equals_core(): void {
+ $email_id = 'change_summary_identical';
+ $this->register_fixture_email( $email_id );
+
+ $content = "<!-- wp:paragraph -->\n<p>Untouched.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $content );
+ $post_id = $this->create_woo_email_post( $email_id, $content );
+
+ $result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertFalse( $result['is_fallback'] );
+ $this->assertSame( array(), $result['summary_lines'] );
+ $this->assertSame( array(), $result['added_blocks'] );
+ $this->assertSame( array(), $result['removed_blocks'] );
+ $this->assertSame( array(), $result['copy_changes'] );
+ $this->assertSame( array(), $result['structural_changes'] );
+ }
+
+ /**
+ * The in-sync zero-result is cached like every other path. Second call for
+ * the same content reports cache_hit: true.
+ *
+ * @testdox Should cache the in-sync zero-result and report cache_hit on the second call.
+ */
+ public function test_summarize_caches_in_sync_zero_result(): void {
+ $email_id = 'change_summary_in_sync_cache';
+ $this->register_fixture_email( $email_id );
+
+ $content = "<!-- wp:paragraph -->\n<p>Same on both sides.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $content );
+ $post_id = $this->create_woo_email_post( $email_id, $content );
+
+ $first = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertFalse( $first['cache_hit'] );
+ $this->assertFalse( $first['is_fallback'] );
+
+ $second = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertTrue( $second['cache_hit'] );
+ $this->assertFalse( $second['is_fallback'] );
+ $this->assertSame( array(), $second['summary_lines'] );
+ }
+
+ /**
+ * Cache: first call computes and stores; second call with same inputs hits
+ * the cache; mutating the post invalidates by content hash.
+ *
+ * @testdox Should cache by content hash and invalidate when the post content changes.
+ */
+ public function test_summarize_caches_and_invalidates_on_content_change(): void {
+ $email_id = 'change_summary_cache';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Original copy.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Edited copy.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $first = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertFalse( $first['cache_hit'], 'First call should be a cache miss.' );
+
+ $second = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertTrue( $second['cache_hit'], 'Second call with identical inputs should hit the cache.' );
+ $this->assertSame( $first['copy_changes'], $second['copy_changes'] );
+
+ // Mutate post content; new content hash → fresh cache key.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => "<!-- wp:paragraph -->\n<p>Different again.</p>\n<!-- /wp:paragraph -->",
+ )
+ );
+ $this->posts_manager->clear_caches();
+
+ $third = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertFalse( $third['cache_hit'], 'After content mutation, the new key should miss the cache.' );
+ }
+
+ /**
+ * Register a fixture email and wire up the sync registry to pick it up.
+ *
+ * @param string $email_id Email ID to assign to the stub.
+ * @return \WC_Email Registered fixture email instance.
+ */
+ private function register_fixture_email( string $email_id ): \WC_Email {
+ $stub = $this->getMockBuilder( \WC_Email::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stub->method( 'get_title' )->willReturn( 'Fixture email for change-summary tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover change-summary scenarios.' );
+ $stub->id = $email_id;
+ $stub->template_base = $this->fixtures_base;
+ $stub->template_block = 'block/third-party-with-version.php';
+ $stub->template_plain = null;
+
+ $class_key = 'WC_Test_Email_' . $email_id;
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ $current[ $class_key ] = $stub;
+ $property->setValue( $emails_container, $current );
+
+ $this->injected_email_keys[] = $class_key;
+
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ static function ( array $emails ) use ( $email_id ): array {
+ if ( ! in_array( $email_id, $emails, true ) ) {
+ $emails[] = $email_id;
+ }
+ return $emails;
+ }
+ );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ return $stub;
+ }
+
+ /**
+ * Hook the canonical content filter so `compute_canonical_post_content()`
+ * returns the supplied string for the given email_id, bypassing the
+ * file-rendered template body. Lets tests express "what core would render"
+ * directly inline.
+ *
+ * @param string $email_id The email ID to override content for.
+ * @param string $content The canonical content to inject.
+ */
+ private function use_canonical_content( string $email_id, string $content ): void {
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( array $post_data, string $type ) use ( $email_id, $content ): array {
+ if ( $type === $email_id ) {
+ $post_data['post_content'] = $content;
+ }
+ return $post_data;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Create a `woo_email` post and associate it with the supplied email_id
+ * via the canonical option key the manager expects.
+ *
+ * @param string $email_id The email ID to associate.
+ * @param string $post_content Initial post content.
+ * @return int Post ID.
+ */
+ private function create_woo_email_post( string $email_id, string $post_content ): int {
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => 'Fixture for ' . $email_id,
+ 'post_name' => $email_id,
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_content' => $post_content,
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertIsInt( $post_id );
+ $this->assertGreaterThan( 0, $post_id );
+
+ $this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+ return (int) $post_id;
+ }
+
+ /**
+ * Remove any stubs we injected into WC_Emails::$emails during the test.
+ */
+ private function cleanup_injected_emails(): void {
+ if ( empty( $this->injected_email_keys ) ) {
+ return;
+ }
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+
+ foreach ( $this->injected_email_keys as $class_key ) {
+ unset( $current[ $class_key ] );
+ }
+
+ $property->setValue( $emails_container, $current );
+ $this->injected_email_keys = array();
+ }
+}