Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails

WCEmailTemplateChangeSummary::diff_records_three_waypublic staticWC 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

Since 10.9.0 Introduced.

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,
	);
}