= 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 {@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; /** * Bumped whenever the classification rules change. Cached payloads survive a * WooCommerce upgrade — the content hashes they are keyed on don't move when * only this code changes — and a payload classified under the old rules would * otherwise drive the new apply behaviour. */ private const CLASSIFIER_VERSION = 2; /** * 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 */ 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 */ 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` 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}>` — 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}>` — 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, 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}>` — `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 * * @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 */ 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 */ 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 $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( self::CLASSIFIER_VERSION . '|' . $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> $blocks Output of `parse_blocks()`. * @param array $path Current index path from root. * @param string|null $parent_name Normalized parent block name, null at root. * * @return array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> */ 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 ) ); $attrs = $block['attrs'] ?? array(); $inner_html = (string) ( $block['innerHTML'] ?? '' ); $records[] = array( 'path' => $current_path, 'parent_name' => $parent_name, 'name' => $name, 'inner_text' => self::clean_inner_text( $inner_html ), 'attrs_hash' => self::attrs_hash( $name, is_array( $attrs ) ? $attrs : array() ), 'inner_html_hash' => self::inner_html_hash( $inner_html ), 'holds_blocks' => ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ), ); if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $records = array_merge( $records, self::flatten_blocks( $block['innerBlocks'], $current_path, $name ) ); } }//end foreach return $records; } /** * Hash a block's attributes so two attribute sets can be compared as strings. * Keys are sorted at every level, so key order never reads as an edit. * * The block editor rewrites some attributes on save, on blocks nobody edited. * Each rewrite we know of is folded into its saved form first, so the PHP * render of a template and the editor's save of it hash the same. The list * is deliberately short and explicit: an unknown rewrite makes a block read * as merchant-edited, which asks the merchant instead of overwriting them. * * @param string $block_name Normalized block name. * @param array $attrs Block attributes from `parse_blocks()`. */ private static function attrs_hash( string $block_name, array $attrs ): string { // Since WordPress 7.1 text alignment is saved as `style.typography.textAlign`: // the `textAlign` attribute on any block, and `align` on paragraphs. Elsewhere // `align` is layout alignment (wide, full, float) and is left alone. $legacy_key = isset( $attrs['textAlign'] ) ? 'textAlign' : ( 'core/paragraph' === $block_name && isset( $attrs['align'] ) ? 'align' : null ); // Hand-authored block JSON can carry a non-array `style`; leave it alone. if ( null !== $legacy_key && is_array( $attrs['style'] ?? array() ) && is_array( $attrs['style']['typography'] ?? array() ) && ! isset( $attrs['style']['typography']['textAlign'] ) ) { $attrs['style']['typography']['textAlign'] = $attrs[ $legacy_key ]; unset( $attrs[ $legacy_key ] ); } self::ksort_recursive( $attrs ); $encoded = wp_json_encode( $attrs ); // An encode failure (invalid UTF-8, or nesting past the depth limit) // would hash every such block alike and read as "unchanged". Fall back // to serialize(), which handles both. return sha1( false === $encoded ? serialize( $attrs ) : $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only; never unserialized. } /** * Sort an array by key at every level, in place. * * @param array $value Array to sort. */ private static function ksort_recursive( array &$value ): void { ksort( $value ); foreach ( $value as &$item ) { if ( is_array( $item ) ) { self::ksort_recursive( $item ); } } } /** * Hash a block's markup. Unlike `inner_text` this keeps inline markup and * HTML comments, which is how personalization tags (``), * links, emphasis and inline styling reach the comparison at all. * * The base side is a PHP template render and the post side was saved by the * block editor, which decodes entities and trims the space just inside a * block's outer tag even on blocks the merchant never touched. Both are * normalized away here so such a block does not read as edited. Spacing * around inline tags is kept: it changes what the merchant sees. * * @param string $inner_html Raw `innerHTML` string from a parsed block. */ private static function inner_html_hash( string $inner_html ): string { $decoded = html_entity_decode( $inner_html, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $collapsed = preg_replace( '/[\s\x{00A0}]+/u', ' ', $decoded ); // Invalid UTF-8 makes `preg_replace()` return null; hashing the raw // markup keeps two such blocks from reading as the same. if ( null === $collapsed ) { return sha1( $inner_html ); } $collapsed = trim( $collapsed ); $trimmed = (string) preg_replace( '/^(<[^>]+>) /', '$1', $collapsed ); $trimmed = (string) preg_replace( '/ (<\/[^>]+>)$/', '$1', $trimmed ); return sha1( $trimmed ); } /** * 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, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side. * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side. * * @return array{added_blocks:array>, removed_blocks:array>, copy_changes:array>, structural_changes:array>} */ 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; } } // Blocks holding other blocks are never replaced by the apply step, so a // change to their own markup is not offered either. if ( $core['inner_text'] !== $post_r['inner_text'] && ! $core['holds_blocks'] && ! $post_r['holds_blocks'] ) { // `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, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side (current canonical). * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $base_records Base side (canonical at last system write). * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side (merchant's current post_content). * * @return array{added_blocks:array>, removed_blocks:array>, copy_changes:array>, structural_changes:array>} * * @since 10.9.0 */ public static function diff_records_three_way( array $core_records, array $base_records, array $post_records ): array { $alignment = self::align_to_base( $core_records, $base_records, $post_records ); $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 = $alignment[ $b_idx ]['core']; $post_idx = $alignment[ $b_idx ]['post']; 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; $yours_changed = self::merchant_changed( $base, $post ); $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; } // The merchant already has core's version, having typed it themselves. // Offering a choice between two identical blocks reads as a bug, and // it counts as a conflict, which holds up the one-click update. if ( $core['inner_html_hash'] === $post['inner_html_hash'] ) { continue; } // Apply never takes the content of a block holding other blocks, so // listing one here would promise a change that cannot happen. Only // its styling follows core, which needs no decision either way. if ( ( $core['holds_blocks'] ?? false ) || ( $post['holds_blocks'] ?? false ) ) { 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, ); } /** * Whether the merchant has touched a block since the base render. Compares * the markup and the attributes, not the stripped text: a swapped * personalization tag, an added link, a colour or a block lock are all * merchant edits, and a block they have edited is never updated without * asking them. When in doubt the answer is "edited": that asks the merchant * rather than overwriting them. * * Core's side is deliberately compared on `inner_text` alone by the callers. * A core change the merchant cannot see as a wording change would otherwise * reach the drawer as an entry whose before and after text are identical. * * Public so {@see WCEmailTemplateSelectiveApplier} decides from the same rule * that produced the summary the merchant reviewed, rather than reading the * classification back out of a cached payload. * * @internal * * @param array{inner_text:string, attrs_hash:string, inner_html_hash:string} $base Base-side record. * @param array{inner_text:string, attrs_hash:string, inner_html_hash:string} $post Post-side record. * * @since 11.3.0 */ public static function merchant_changed( array $base, array $post ): bool { return $base['inner_html_hash'] !== $post['inner_html_hash'] || $base['attrs_hash'] !== $post['attrs_hash']; } /** * Match each base block to its counterpart in core and in the post. A core * block and a post block are the same logical block only when both match * the same base block. `null` means that side no longer has the block. * * Public so {@see WCEmailTemplateSelectiveApplier} pairs blocks exactly the * way the change summary shown to the merchant does. * * @internal * * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side (current canonical). * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $base_records Base side (canonical at last system write). * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side (merchant's current post_content). * * @return array Keyed by base record index; values are core and post record indices. * * @since 11.3.0 */ public static function align_to_base( array $core_records, array $base_records, array $post_records ): array { $base_to_core = array(); foreach ( self::lcs_matches( $core_records, $base_records ) as $pair ) { $base_to_core[ $pair[1] ] = $pair[0]; } $base_to_post = array(); foreach ( self::lcs_matches( $post_records, $base_records ) as $pair ) { $base_to_post[ $pair[1] ] = $pair[0]; } $alignment = array(); foreach ( array_keys( $base_records ) as $b_idx ) { $alignment[ $b_idx ] = array( 'core' => $base_to_core[ $b_idx ] ?? null, 'post' => $base_to_post[ $b_idx ] ?? null, ); } return $alignment; } /** * 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> $entries Source list. * @param array $drop Indices to drop. * * @return array> */ 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, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $a Core records. * @param array, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $b Post records. * * @return array */ 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>, removed_blocks:array>, copy_changes:array>, structural_changes:array>} $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; } }