Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
WCEmailTemplateChangeSummary{} │ WC 10.9.0
Produces a localized summary of differences between a merchant's woo_email post content and the current canonical core render of the same email.
Two diff modes, selected per-call by post-meta presence:
- Three-way (since 10.9.0) — when the post has {@see WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY} meta (every post touched by a sync-eligible writer: generator, auto-applier, selective applier, reset endpoint, RSM-149 backfill), the summary runs two LCS passes (yours-vs-base, core-vs-base) and classifies each block by what changed relative to base. The diff is deterministic on any post; the inversion guard does not fire on this path.
- Two-way (legacy fallback) — when the meta is absent (legacy posts not yet touched since the meta was introduced), the summary falls back to the original
lcs_matches(core, post)algorithm. The summary- inversion guard (>= 5 unmatched && 0 copy && post >= 1.5x core) is active in this path only — it bails to "Template updated — see release notes." when it can't reliably attribute changes.
Both paths normalize known namespace aliases (e.g. woo/email-content → woocommerce/email-content) and produce the same payload shape (added / removed / copy / structural) so consumers don't need a mode switch.
Result is cached in a transient keyed on the post ID, the post + core + base content hashes, and the active locale; any merchant edit, core bump, or base shift invalidates automatically.
Hash input parity with WCTransactionalEmailPostsGenerator::compute_canonical_post_content() is guaranteed by construction — both sides route through the same canonical render, identical to the divergence detector.
No Hooks.
Usage
$WCEmailTemplateChangeSummary = new WCEmailTemplateChangeSummary(); // use class methods
Methods
- public static diff_records_three_way( array $core_records, array $base_records, array $post_records )
- public static flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null )
- public static lcs_matches( array $a, array $b )
- public static reset_cache()
- public static set_logger( ?Email_Editor_Logger_Interface $logger )
- public static summarize( int $post_id )
- private static block_label( string $normalized_name )
- private static cache_key( int $post_id, string $post_hash, string $core_hash, string $base_hash, string $locale )
- private static clean_inner_text( string $html )
- private static current_locale()
- private static diff_records( array $core_records, array $post_records )
- private static empty_payload()
- private static fallback_payload( string $version_from, string $version_to )
- private static get_logger()
- private static normalize_block_name( string $name )
- private static reject_indices( array $entries, array $drop )
- private static similarity_score( string $a, string $b )
- private static to_summary_lines( array $structured )
- private static truncate_text( string $text )
- private static write_cache( string $cache_key, array $payload )
Notes
- Package: Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
Changelog
| Since 10.9.0 | Introduced. |
WCEmailTemplateChangeSummary{} WCEmailTemplateChangeSummary{} code WC 10.9.1
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.
* - `source_hash_to` — `string` — sha1 of the canonical core content for this email type. Mirrors the post's `_wc_email_template_source_hash` meta. Empty string in fallback / no-config paths where the core content can't be computed.
* - `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>, auto_resolvable?:bool}>`.
* `before` is the merchant's current text; `after` is the canonical core text. `path` is the post-side index path.
* `auto_resolvable` is emitted only on the three-way path: `true` when only core changed since base (safe to auto-apply), `false` when both sides changed (true conflict). Absent on two-way fallback payloads.
* - `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 );
$base_render = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
$base_hash = '' !== $base_render ? sha1( $base_render ) : '';
$cache_key = self::cache_key( $post_id, $post_hash, $core_hash, $base_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;
$payload['source_hash_to'] = $core_hash;
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 );
}
// Branch on base meta availability. When the post has been touched by a
// sync-eligible writer (generator, auto-applier, selective applier, reset,
// backfill), it has `last_core_render` and we run the three-way diff: a
// strict per-block (yours-vs-base, core-vs-base) comparison. The
// inversion-guard heuristic is not needed in this branch — three-way is
// deterministic on any post. Posts without the meta fall through to the
// legacy two-way path, which keeps the inversion guard for safety.
if ( '' !== $base_render ) {
$base_records = self::flatten_blocks( parse_blocks( $base_render ) );
$structured = self::diff_records_three_way( $core_records, $base_records, $post_records );
} else {
$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 render 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,
'source_hash_to' => $core_hash,
'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' => '',
'source_hash_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 $base_hash sha1 of `_wc_email_template_last_core_render` if set; empty string otherwise. Including this in the key invalidates the cache when base shifts.
* @param string $locale Active locale.
*/
private static function cache_key( int $post_id, string $post_hash, string $core_hash, string $base_hash, string $locale ): string {
return sprintf(
'wc_email_change_summary_%d_%s',
$post_id,
md5( $post_hash . '|' . $core_hash . '|' . $base_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.
*
* Public so the RSM-143 selective-merge engine
* ({@see WCEmailTemplateSelectiveApplier}) can reuse this and
* {@see self::lcs_matches()} to align matched pairs without duplicating the
* algorithm. Internal namespace-bounded; not part of any external contract.
*
* @internal
*
* @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}>
*/
public 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,
);
}
/**
* Compute a three-way block-level diff between core, base, and yours.
*
* Two LCS passes (yours-vs-base, core-vs-base) build a tripartite alignment
* keyed on base. For each base block, we then know whether yours and/or core
* has changed it relative to base — yielding a four-case classification:
*
* - !yours_changed && !core_changed → no entry (block unchanged on both sides)
* - !yours_changed && core_changed → copy_change (auto-resolvable to use_core)
* - yours_changed && !core_changed → no entry (merchant edit; preserve silently)
* - yours_changed && core_changed → copy_change (true conflict)
*
* Additions (in yours OR core but not in base) and removals (in base but not in
* one side) are handled in dedicated passes after the matched-pair classification.
* Yours-only adds become `removed_blocks` ("would be removed by wholesale apply,
* preserved by selective apply"). Core-only adds become `added_blocks`. Yours-removed
* but core-kept becomes a `merchant_removed` structural entry — apply does NOT re-add.
*
* Compared to {@see self::diff_records()}, this method removes the inversion-guard
* heuristic entirely: with a known base, the diff is deterministic on any post,
* including heavily-customized ones. The reorder pass is also dropped: the LCS
* tail-pairing bug it compensated for cannot fire under three-way attribution.
*
* Structural relocations (a matched pair whose `parent_name` differs between
* core and post) are intentionally not surfaced here, unlike the 2-way path's
* `Moved %1$s into %2$s` entry. Selective apply preserves the merchant's
* structure either way, so the move is something the merchant cannot act on.
*
* @internal
*
* @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side (current canonical).
* @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $base_records Base side (canonical at last system write).
* @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side (merchant's current post_content).
*
* @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>>}
*
* @since 10.9.0
*/
public static function diff_records_three_way( array $core_records, array $base_records, array $post_records ): array {
$core_to_base = self::lcs_matches( $core_records, $base_records );
$post_to_base = self::lcs_matches( $post_records, $base_records );
// Invert into base-keyed lookups so a single iteration over base records
// can decide each block's fate against both sides.
$base_to_core = array();
foreach ( $core_to_base as $pair ) {
$base_to_core[ $pair[1] ] = $pair[0];
}
$base_to_post = array();
foreach ( $post_to_base as $pair ) {
$base_to_post[ $pair[1] ] = $pair[0];
}
$matched_core_indices = array();
$matched_post_indices = array();
$copy_changes = array();
$added_blocks = array();
$removed_blocks = array();
$structural_changes = array();
$core_name_counts = array_count_values( array_map( static fn( array $r ): string => $r['name'], $core_records ) );
$occurrence_index = array();
// Pass 1: classify each base-anchored block by what changed relative to base.
foreach ( $base_records as $b_idx => $base ) {
$core_idx = $base_to_core[ $b_idx ] ?? null;
$post_idx = $base_to_post[ $b_idx ] ?? null;
if ( null !== $core_idx ) {
$matched_core_indices[ $core_idx ] = true;
}
if ( null !== $post_idx ) {
$matched_post_indices[ $post_idx ] = true;
}
if ( null === $core_idx && null === $post_idx ) {
// Both sides removed it — no-op.
continue;
}
if ( null === $core_idx ) {
// Core removed it; yours kept it. Preserve on apply.
$removed_blocks[] = array(
'name' => $post_records[ $post_idx ]['name'],
'label' => self::block_label( $post_records[ $post_idx ]['name'] ),
'path' => $post_records[ $post_idx ]['path'],
);
continue;
}
if ( null === $post_idx ) {
// Yours removed it; core kept it. Don't re-add — respect merchant intent.
$structural_changes[] = array(
'kind' => 'merchant_removed',
'description' => sprintf(
/* translators: %s: block name */
__( 'You removed %s; core still has it.', 'woocommerce' ),
self::block_label( $core_records[ $core_idx ]['name'] )
),
'path' => $base['path'],
);
continue;
}
// Both sides have the block — increment occurrence ordinal for every
// matched pair, regardless of whether a copy_change is emitted, so
// "Paragraph N of M" reflects the block's true ordinal across the run.
// Mirrors the 2-way `diff_records()` placement of the counter.
$core = $core_records[ $core_idx ];
$post = $post_records[ $post_idx ];
$name = $core['name'];
$occurrence_index[ $name ] = ( $occurrence_index[ $name ] ?? 0 ) + 1;
// Known limitation: comparison is `inner_text` only; block `attrs` (colors,
// alignment, etc.) don't register as changes. With `auto_resolvable: true`
// the drawer can silently overwrite an attr-only merchant edit. Follow-up
// to extend the comparison to a stable hash of `attrs`.
$yours_changed = ( $base['inner_text'] !== $post['inner_text'] );
$core_changed = ( $base['inner_text'] !== $core['inner_text'] );
if ( ! $yours_changed && ! $core_changed ) {
continue;
}
if ( ! $core_changed ) {
// Yours edited, core didn't — merchant-only edit, preserve silently.
continue;
}
// Core changed (with or without yours also changing).
$copy_changes[] = array(
'block' => self::block_label( $name ),
'before' => self::truncate_text( $post['inner_text'] ),
'after' => self::truncate_text( $core['inner_text'] ),
'occurrence' => $occurrence_index[ $name ],
'total' => (int) ( $core_name_counts[ $name ] ?? 1 ),
'path' => $post['path'],
'auto_resolvable' => ! $yours_changed,
);
}//end foreach
// Pass 2: unmatched core records → added_blocks. Structural wrappers
// (`core/group`, `core/columns`, `core/column`, `core/row`) route to
// `structural_changes` instead — the selective applier skips them at
// merge time, and surfacing them as `added_blocks` would advertise an
// "Added Group block" the apply will never apply. Mirrors the 2-way
// `diff_records()` handling of structural wrappers.
foreach ( $core_records as $c_idx => $rec ) {
if ( isset( $matched_core_indices[ $c_idx ] ) ) {
continue;
}
if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
$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'],
);
}
// Pass 3: unmatched post records → removed_blocks (yours-only additions, preserved by default).
// Same structural-wrapper handling as Pass 2 — yours-only structural blocks land in
// `structural_changes` rather than `removed_blocks`.
foreach ( $post_records as $p_idx => $rec ) {
if ( isset( $matched_post_indices[ $p_idx ] ) ) {
continue;
}
if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
$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'],
);
}
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.
*
* Public so {@see WCEmailTemplateSelectiveApplier} can reuse the same
* matched-pair alignment when applying merchant choices. Internal-namespace
* bounded; not part of any external contract.
*
* @internal
*
* @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}>
*/
public 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;
}
}