Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
WCEmailTemplateChangeSummary::diff_records_three_way │ public static │ WC 10.9.0 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.
Method of the class: WCEmailTemplateChangeSummary{}
No Hooks.
Returns
Array{added_blocks:Array. array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>}
Usage
$result = WCEmailTemplateChangeSummary::diff_records_three_way( $core_records, $base_records, $post_records ): array;
$core_records(array) (required)
.
$base_records(array) (required)
.
$post_records(array) (required)
.
Changelog
WCEmailTemplateChangeSummary::diff_records_three_way() WCEmailTemplateChangeSummary::diff records three way code
WC 10.9.1
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,
);
}