Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails

WCEmailTemplateChangeSummary::diff_recordsprivate staticWC 1.0

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.

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( $core_records, $post_records ): array;
$core_records(array) (required)
.
$post_records(array) (required)
.

WCEmailTemplateChangeSummary::diff_records() code WC 10.9.1

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