PluginProbe
WooCommerce / 11.0.1
WooCommerce v11.0.1
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 All 648 releases
woocommerce / src / Internal / EmailEditor / WCTransactionalEmails / WCEmailTemplateSelectiveApplier.php
WCEmailTemplateSelectiveApplier.php
891 lines 33.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare( strict_types=1 );
4
5 namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
6
7 use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
8 use Automattic\WooCommerce\Internal\EmailEditor\Integration;
9 use Automattic\WooCommerce\Internal\EmailEditor\Logger;
10
11 /**
12 * Applies a partial set of core template changes to a customised `woo_email`
13 * post, driven by per-conflict merchant choices. Pairs with
14 * {@see WCEmailTemplateChangeSummary} (the diff data source) and
15 * {@see WCEmailTemplateAutoApplier} (the wholesale-apply primitive) to power
16 * the Review drawer's "Keep yours / Use core" workflow.
17 *
18 * V1 algorithm (spine = the merchant's post):
19 *
20 * - **`copy_changes`** (matched pair, different inner_text): default decision
21 * is `keep_yours`. When the merchant explicitly opts into `use_core`, the
22 * matched block's `innerHTML` / `innerContent` is replaced with core's
23 * version. Block `attrs` are preserved from the post side (no attribute
24 * diff in v1).
25 * - **`added_blocks`** (in core, not in post): always applied. Inserted at
26 * the equivalent position from core's path; if the path can't be navigated
27 * in the post tree, falls back to appending at the closest level.
28 * - **`removed_blocks`** (in post, not in core): always preserved (Keep
29 * yours).
30 * - **`structural_changes`** (`nest` / `reorder`): not applied in v1. The
31 * merchant's structure is preserved; the response carries
32 * `structural_skipped: true` if any structural delta was observed.
33 *
34 * Undo: each apply writes a single-step snapshot of the prior `post_content`
35 * to {@see self::SNAPSHOT_META_KEY}, keyed by a UUID `revision_id`. A
36 * subsequent apply overwrites the snapshot. {@see self::undo()} restores from
37 * the snapshot when the supplied `revision_id` matches.
38 *
39 * Three-way payload consumption (since 10.9.0): when the post has
40 * {@see WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY} meta,
41 * `apply_selectively()` passes the change-summary's payload through to
42 * `merge()`, which uses it to gate matched-pair classification:
43 *
44 * - LCS pairs whose paths the summary classified as separate add+remove are
45 * rejected by Pass 1 (preventing false yours+core pairings on parallel
46 * additions); Pass 2/3 then handle them as two independent adds.
47 * - Matched pairs whose paths are NOT in `copy_changes` are silently
48 * preserved (yours-only edits aren't conflicts; the `use_core` decision
49 * is ignored on those paths).
50 *
51 * Posts without the meta keep the legacy two-way behavior — `merge()` runs
52 * its own LCS and treats every text-divergent matched pair as a candidate
53 * for `use_core`.
54 *
55 * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
56 * @since 10.9.0
57 */
58 class WCEmailTemplateSelectiveApplier {
59 /**
60 * Post meta key for the single-step pre-apply snapshot. Stores an array
61 * with `revision_id`, `content`, `last_core_render`, `version`,
62 * `source_hash`, `last_synced_at`, and `snapshot_at` (UTC `Y-m-d H:i:s`).
63 * The status is **not** recorded — on undo it is recomputed via
64 * {@see WCEmailTemplateDivergenceDetector::reclassify()} against current
65 * state.
66 *
67 * @var string
68 */
69 public const SNAPSHOT_META_KEY = '_wc_email_template_pre_apply_snapshot';
70
71 /**
72 * Re-entrancy flag set while the applier rewrites a post. Mirrors
73 * {@see WCEmailTemplateAutoApplier::is_auto_applying()}; future
74 * `save_post` listeners (RSM-145 Tracks event firing) should consult both
75 * before treating a write as a merchant edit.
76 *
77 * @var bool
78 */
79 private static bool $is_applying = false;
80
81 /**
82 * Logger instance. Lazily instantiated on first use; overridable for tests.
83 *
84 * @var Email_Editor_Logger_Interface|null
85 */
86 private static ?Email_Editor_Logger_Interface $logger = null;
87
88 /**
89 * Apply the selected set of core template changes to a `woo_email` post.
90 *
91 * @param int $post_id The `woo_email` post ID.
92 * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices keyed implicitly by `path`. `decision` is `'keep_yours'` (default if absent) or `'use_core'`. Choices for paths that don't correspond to a `copy_changes` entry are ignored — auto-resolved entries are non-overridable in v1.
93 *
94 * @return array<string, mixed>|\WP_Error On success, an array with keys
95 * `merged_content`, `revision_id`,
96 * `version_to`, `status` ('applied'),
97 * `structural_skipped`, and
98 * `aliases_migrated` (a list of
99 * deprecated block-name aliases
100 * rewritten to canonical form
101 * during the apply, e.g.
102 * `['woo/email-content']`).
103 *
104 * @since 10.9.0
105 */
106 public static function apply_selectively( int $post_id, array $choices ) {
107 $post = get_post( $post_id );
108 if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
109 return new \WP_Error(
110 'post_not_found',
111 sprintf(
112 /* translators: %d: post ID */
113 __( 'No woo_email post found for ID %d.', 'woocommerce' ),
114 $post_id
115 ),
116 array( 'status' => 404 )
117 );
118 }
119
120 $posts_manager = WCTransactionalEmailPostsManager::get_instance();
121 $email_id = $posts_manager->get_email_type_from_post_id( $post_id );
122 if ( ! is_string( $email_id ) || '' === $email_id ) {
123 return new \WP_Error(
124 'email_not_found',
125 __( 'No email type associated with the given post ID.', 'woocommerce' ),
126 array( 'status' => 404 )
127 );
128 }
129
130 $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
131 if ( null === $sync_config ) {
132 return new \WP_Error(
133 'not_sync_enabled',
134 sprintf(
135 /* translators: %s: email ID */
136 __( 'Email "%s" is not registered for template sync; selective apply is unavailable.', 'woocommerce' ),
137 $email_id
138 ),
139 array( 'status' => 422 )
140 );
141 }
142
143 $emails = $posts_manager->get_emails_by_id();
144 $email = $emails[ $email_id ] ?? null;
145 if ( ! $email instanceof \WC_Email ) {
146 return new \WP_Error(
147 'email_not_found',
148 sprintf(
149 /* translators: %s: email ID */
150 __( 'Email instance for "%s" is unavailable.', 'woocommerce' ),
151 $email_id
152 ),
153 array( 'status' => 404 )
154 );
155 }
156
157 $summary = WCEmailTemplateChangeSummary::summarize( $post_id );
158 if ( ! empty( $summary['is_fallback'] ) ) {
159 return new \WP_Error(
160 'no_actionable_summary',
161 __( 'No actionable diff is available for this post; refusing to apply.', 'woocommerce' ),
162 array( 'status' => 422 )
163 );
164 }
165
166 $post_content = (string) $post->post_content;
167
168 try {
169 $core_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
170 } catch ( \Throwable $e ) {
171 self::get_logger()->error(
172 sprintf(
173 'Selective apply failed to compute canonical content for email "%s": %s',
174 $email_id,
175 $e->getMessage()
176 ),
177 array(
178 'email_id' => $email_id,
179 'post_id' => $post_id,
180 'context' => 'email_template_selective_applier',
181 )
182 );
183 return new \WP_Error(
184 'canonical_render_failed',
185 __( 'Failed to compute the canonical core render.', 'woocommerce' ),
186 array( 'status' => 500 )
187 );
188 }//end try
189
190 // When the post has `last_core_render` meta, the change-summary already classified
191 // each block via three-way attribution (yours-vs-base, core-vs-base) and the merge
192 // can consume that payload directly — gating use_core decisions to "real" conflicts
193 // only and rejecting LCS pairs that the summary classified as separate add+remove.
194 $base_render_for_merge = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
195 $summary_for_merge = '' !== $base_render_for_merge ? $summary : null;
196
197 $merged_result = self::merge( $post_content, $core_content, $choices, $summary_for_merge );
198 $merged_content = $merged_result['content'];
199 $structural_skipped = $merged_result['structural_skipped'];
200 $aliases_migrated = $merged_result['aliases_migrated'];
201
202 // Alignment is decided on whitespace-normalized content: serialize_blocks()
203 // can't reproduce the PHP template's literal whitespace byte-for-byte, so a
204 // strict === would never match even when the trees are semantically equal.
205 // When aligned, persist canonical verbatim so source_hash, classify_post,
206 // and downstream byte comparisons hold without further normalization.
207 $is_aligned_with_canonical = (
208 self::normalize_for_comparison( $merged_content )
209 === self::normalize_for_comparison( $core_content )
210 );
211 if ( $is_aligned_with_canonical ) {
212 $merged_content = $core_content;
213 }
214
215 // Snapshot every meta apply is about to overwrite. Restoring only a
216 // subset would leave the banner / email-list gates reading stale
217 // post-apply `_wc_email_template_version`, hiding the pending update.
218 $prior_last_core_render = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
219 $prior_version = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
220 $prior_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
221 $prior_last_synced_at = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true );
222
223 $revision_id = wp_generate_uuid4();
224 $snapshot = array(
225 'revision_id' => $revision_id,
226 'content' => $post_content,
227 'last_core_render' => $prior_last_core_render,
228 'version' => $prior_version,
229 'source_hash' => $prior_source_hash,
230 'last_synced_at' => $prior_last_synced_at,
231 'snapshot_at' => gmdate( 'Y-m-d H:i:s' ),
232 );
233 update_post_meta( $post_id, self::SNAPSHOT_META_KEY, $snapshot );
234
235 self::$is_applying = true;
236 try {
237 $updated = wp_update_post(
238 array(
239 'ID' => $post_id,
240 'post_content' => $merged_content,
241 ),
242 true
243 );
244
245 if ( is_wp_error( $updated ) ) {
246 delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
247 return $updated;
248 }
249
250 $saved_post = get_post( $post_id );
251 $saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $merged_content;
252
253 $source_hash = $is_aligned_with_canonical
254 ? sha1( $saved_body )
255 : sha1( $core_content );
256 $synced_at = gmdate( 'Y-m-d H:i:s' );
257 $version_to = (string) $sync_config['version'];
258
259 update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version_to );
260 update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
261 update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
262 // Three-way diff base reference: "what core looked like the last time we synced."
263 // Always stamps the current canonical (NOT the merged content) — selective apply IS
264 // a sync against the new canonical even if the merchant kept some yours-blocks.
265 update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $core_content );
266
267 if ( $is_aligned_with_canonical ) {
268 WCEmailTemplateDivergenceDetector::reclassify( $post_id );
269 } else {
270 // reclassify() returns null in this branch (current_core ===
271 // stored, current_post !== stored) and would leave prior status
272 // untouched, so stamp directly.
273 update_post_meta(
274 $post_id,
275 WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
276 WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
277 );
278 }
279 } finally {
280 self::$is_applying = false;
281 }//end try
282
283 // Invalidate the change-summary cache so the next read reflects the merged state.
284 WCEmailTemplateChangeSummary::reset_cache();
285
286 // Fire `_update_applied` for the selective-applier path. Static extensions:
287 // the selective applier only acts on `core_updated_customized` posts, so
288 // `had_customizations` is always true and `auto_resolved` is always false.
289 WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
290
291 return array(
292 'merged_content' => $merged_content,
293 'revision_id' => $revision_id,
294 'version_to' => $version_to,
295 'status' => 'applied',
296 'structural_skipped' => $structural_skipped,
297 'aliases_migrated' => $aliases_migrated,
298 );
299 }
300
301 /**
302 * Restore the pre-apply snapshot for a post. Single-step undo only: the
303 * snapshot meta is consumed (deleted) on success, so a second undo
304 * without an intervening apply returns 410 Gone.
305 *
306 * @param int $post_id The `woo_email` post ID.
307 * @param string $revision_id The UUID returned by the prior `apply_selectively()` call.
308 *
309 * @return array<string, mixed>|\WP_Error On success, an array with keys
310 * `restored_content` and `status`
311 * ('restored').
312 *
313 * @since 10.9.0
314 */
315 public static function undo( int $post_id, string $revision_id ) {
316 $post = get_post( $post_id );
317 if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
318 return new \WP_Error(
319 'post_not_found',
320 sprintf(
321 /* translators: %d: post ID */
322 __( 'No woo_email post found for ID %d.', 'woocommerce' ),
323 $post_id
324 ),
325 array( 'status' => 404 )
326 );
327 }
328
329 $snapshot = get_post_meta( $post_id, self::SNAPSHOT_META_KEY, true );
330 if ( ! is_array( $snapshot ) || ! isset( $snapshot['revision_id'], $snapshot['content'] ) ) {
331 return new \WP_Error(
332 'undo_unavailable',
333 __( 'No pre-apply snapshot is available for this post.', 'woocommerce' ),
334 array( 'status' => 410 )
335 );
336 }
337
338 if ( (string) $snapshot['revision_id'] !== $revision_id ) {
339 return new \WP_Error(
340 'undo_unavailable',
341 __( 'The supplied revision ID does not match the latest snapshot for this post.', 'woocommerce' ),
342 array( 'status' => 410 )
343 );
344 }
345
346 $restored_content = (string) $snapshot['content'];
347
348 self::$is_applying = true;
349 try {
350 $updated = wp_update_post(
351 array(
352 'ID' => $post_id,
353 'post_content' => $restored_content,
354 ),
355 true
356 );
357
358 if ( is_wp_error( $updated ) ) {
359 return $updated;
360 }
361
362 // Restore every meta apply stamped. `restore_meta_from_snapshot`
363 // no-ops on keys missing from older snapshot formats.
364 self::restore_meta_from_snapshot(
365 $post_id,
366 $snapshot,
367 'last_core_render',
368 WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY
369 );
370 self::restore_meta_from_snapshot(
371 $post_id,
372 $snapshot,
373 'version',
374 WCEmailTemplateDivergenceDetector::VERSION_META_KEY
375 );
376 self::restore_meta_from_snapshot(
377 $post_id,
378 $snapshot,
379 'source_hash',
380 WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY
381 );
382 self::restore_meta_from_snapshot(
383 $post_id,
384 $snapshot,
385 'last_synced_at',
386 WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY
387 );
388
389 // The snapshot's prior_status was correct at snapshot time, but
390 // the world may have moved since (core released, canonical
391 // changed). Ask the classifier for the truth against current
392 // state instead of stamping a stale value.
393 WCEmailTemplateDivergenceDetector::reclassify( $post_id );
394
395 delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
396 } finally {
397 self::$is_applying = false;
398 }//end try
399
400 WCEmailTemplateChangeSummary::reset_cache();
401
402 return array(
403 'restored_content' => $restored_content,
404 'status' => 'restored',
405 );
406 }
407
408 /**
409 * Whether the applier is currently rewriting a post. Mirrors the
410 * auto-applier's flag so downstream listeners can ignore system writes.
411 *
412 * @since 10.9.0
413 */
414 public static function is_applying(): bool {
415 return self::$is_applying;
416 }
417
418 /**
419 * Override the logger implementation. Intended for tests only.
420 *
421 * @internal
422 *
423 * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
424 */
425 public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
426 self::$logger = $logger;
427 }
428
429 /**
430 * Restore one post meta from a snapshot entry. Empty prior values delete
431 * the meta rather than writing an empty string; missing keys (older
432 * snapshot format) no-op.
433 *
434 * @param int $post_id The post ID.
435 * @param array<string, mixed> $snapshot The snapshot array stored in SNAPSHOT_META_KEY.
436 * @param string $snapshot_key The key inside the snapshot array.
437 * @param string $post_meta_key The post meta key to write back to.
438 */
439 private static function restore_meta_from_snapshot( int $post_id, array $snapshot, string $snapshot_key, string $post_meta_key ): void {
440 if ( ! array_key_exists( $snapshot_key, $snapshot ) ) {
441 return;
442 }
443 $value = (string) $snapshot[ $snapshot_key ];
444 if ( '' !== $value ) {
445 update_post_meta( $post_id, $post_meta_key, $value );
446 } else {
447 delete_post_meta( $post_id, $post_meta_key );
448 }
449 }
450
451 /**
452 * Compute the merged block tree, starting from the merchant's post and
453 * layering on core's changes per the v1 algorithm.
454 *
455 * When `$precomputed_summary` is provided (the caller's `last_core_render`
456 * meta was set, so the change-summary ran three-way attribution), the merge
457 * defers to the summary's classification:
458 *
459 * - Matched pairs whose path is in `removed_blocks` (yours-only) or
460 * `added_blocks` (core-only) are REJECTED — the summary correctly
461 * identified them as separate adds; the local LCS may have falsely
462 * paired them by name. The reject lets Pass 2 / Pass 3 handle them.
463 * - Matched pairs not in `copy_changes` are silent — Pass 1 skips them
464 * even if a `use_core` decision was passed (yours-only edit, no
465 * conflict to resolve).
466 *
467 * Without `$precomputed_summary` (legacy two-way fallback), the existing
468 * behavior is preserved: every matched pair with differing inner_text is
469 * eligible for `use_core`, and the local LCS drives matched-set tracking.
470 *
471 * @param string $post_content Merchant's current `post_content`.
472 * @param string $core_content Canonical core render.
473 * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices.
474 * @param array<string, mixed>|null $precomputed_summary Optional three-way summary payload from {@see WCEmailTemplateChangeSummary::summarize()}; pass `null` to use the legacy two-way merge.
475 *
476 * @return array{content:string, structural_skipped:bool, aliases_migrated:string[]}
477 */
478 private static function merge( string $post_content, string $core_content, array $choices, ?array $precomputed_summary = null ): array {
479 $post_blocks = parse_blocks( $post_content );
480 $core_blocks = parse_blocks( $core_content );
481
482 if ( empty( $post_blocks ) || empty( $core_blocks ) ) {
483 return array(
484 'content' => $post_content,
485 'structural_skipped' => false,
486 'aliases_migrated' => array(),
487 );
488 }
489
490 $post_records = WCEmailTemplateChangeSummary::flatten_blocks( $post_blocks );
491 $core_records = WCEmailTemplateChangeSummary::flatten_blocks( $core_blocks );
492 $matches = WCEmailTemplateChangeSummary::lcs_matches( $core_records, $post_records );
493
494 $choice_map = array();
495 foreach ( $choices as $choice ) {
496 if ( ! is_array( $choice ) || ! isset( $choice['path'] ) || ! is_array( $choice['path'] ) ) {
497 continue;
498 }
499 $decision = (string) ( $choice['decision'] ?? 'keep_yours' );
500 if ( 'use_core' !== $decision && 'keep_yours' !== $decision ) {
501 continue;
502 }
503 $choice_map[ self::path_key( $choice['path'] ) ] = $decision;
504 }
505
506 // Three-way overrides derived from the precomputed summary. `null`
507 // signals the legacy two-way path (no gating).
508 $copy_change_paths = null;
509 $added_path_keys = array();
510 $removed_path_keys = array();
511 if ( null !== $precomputed_summary ) {
512 $copy_change_paths = array();
513 foreach ( $precomputed_summary['copy_changes'] ?? array() as $cc ) {
514 if ( isset( $cc['path'] ) && is_array( $cc['path'] ) ) {
515 $copy_change_paths[ self::path_key( $cc['path'] ) ] = true;
516 }
517 }
518 foreach ( $precomputed_summary['added_blocks'] ?? array() as $ab ) {
519 if ( isset( $ab['path'] ) && is_array( $ab['path'] ) ) {
520 $added_path_keys[ self::path_key( $ab['path'] ) ] = true;
521 }
522 }
523 foreach ( $precomputed_summary['removed_blocks'] ?? array() as $rb ) {
524 if ( isset( $rb['path'] ) && is_array( $rb['path'] ) ) {
525 $removed_path_keys[ self::path_key( $rb['path'] ) ] = true;
526 }
527 }
528 }
529
530 // Pass 1: matched pairs. Apply use_core decisions on copy changes;
531 // detect parent-name diffs (structural punted, but we still flag
532 // `structural_skipped` so the caller can surface it).
533 $structural_skipped = false;
534 $matched_core_set = array();
535 $matched_post_set = array();
536 foreach ( $matches as $pair ) {
537 $core_rec = $core_records[ $pair[0] ];
538 $post_rec = $post_records[ $pair[1] ];
539 $core_key = self::path_key( $core_rec['path'] );
540 $post_key = self::path_key( $post_rec['path'] );
541
542 // Three-way reject: applier's LCS paired these but the summary
543 // classified them as separate add+remove. Don't track as matched
544 // (so Pass 2 / Pass 3 will handle them) and don't apply.
545 if ( null !== $precomputed_summary
546 && ( isset( $added_path_keys[ $core_key ] ) || isset( $removed_path_keys[ $post_key ] ) )
547 ) {
548 continue;
549 }
550
551 $matched_core_set[ $pair[0] ] = true;
552 $matched_post_set[ $pair[1] ] = true;
553
554 if ( $core_rec['parent_name'] !== $post_rec['parent_name'] ) {
555 $structural_skipped = true;
556 }
557
558 if ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
559 continue;
560 }
561
562 // Three-way gate: only paths the summary surfaced as `copy_changes`
563 // are eligible for `use_core`. Yours-only edits are silently
564 // preserved — they aren't conflicts.
565 if ( null !== $copy_change_paths && ! isset( $copy_change_paths[ $post_key ] ) ) {
566 continue;
567 }
568
569 $decision = $choice_map[ $post_key ] ?? 'keep_yours';
570 if ( 'use_core' !== $decision ) {
571 continue;
572 }
573
574 $core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
575 if ( null === $core_block ) {
576 continue;
577 }
578 $post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block );
579 }//end foreach
580
581 // Pass 2: unmatched core records. Insert non-structural blocks at
582 // the equivalent path; flag structural wrappers as skipped.
583 $insertions = array();
584 foreach ( $core_records as $i => $rec ) {
585 if ( isset( $matched_core_set[ $i ] ) ) {
586 continue;
587 }
588 if ( self::is_structural_block( $rec['name'] ) ) {
589 $structural_skipped = true;
590 continue;
591 }
592 $core_block = self::block_at_path( $core_blocks, $rec['path'] );
593 if ( null === $core_block ) {
594 continue;
595 }
596 $insertions[] = array(
597 'path' => $rec['path'],
598 'block' => $core_block,
599 );
600 }
601
602 // Insert in order of decreasing path-depth+index so each insert's
603 // target index isn't shifted by a prior insert at the same level.
604 usort(
605 $insertions,
606 static function ( array $a, array $b ): int {
607 $path_a = $a['path'];
608 $path_b = $b['path'];
609 $depth_cmp = count( $path_b ) - count( $path_a );
610 if ( 0 !== $depth_cmp ) {
611 return $depth_cmp;
612 }
613 $last_a = end( $path_a );
614 $last_b = end( $path_b );
615 return ( (int) $last_b ) - ( (int) $last_a );
616 }
617 );
618 foreach ( $insertions as $insertion ) {
619 $post_blocks = self::insert_block_at_path( $post_blocks, $insertion['path'], $insertion['block'] );
620 }
621
622 // Pass 3: unmatched post records (`removed_blocks`). Auto-resolved
623 // as Keep yours — no change. Detect structural wrappers solely so
624 // we can flag `structural_skipped` honestly.
625 foreach ( $post_records as $i => $rec ) {
626 if ( isset( $matched_post_set[ $i ] ) ) {
627 continue;
628 }
629 if ( self::is_structural_block( $rec['name'] ) ) {
630 $structural_skipped = true;
631 }
632 }
633
634 // Final pass: explicit deprecated-namespace migration. Whenever a
635 // `wp:woo/email-content` block is found in the merged tree, rewrite
636 // it to the canonical `wp:woocommerce/email-content` form, including
637 // the `wp-block-{old}` CSS class on the inner div so the comment and
638 // class stay consistent. The block's `attrs` and inner content are
639 // preserved — only the namespace label changes. This is unconditional
640 // (independent of `choices`) because `woo/email-content` is a known
641 // alias of the canonical core block, not a customisation worth
642 // preserving.
643 $aliases_migrated = array();
644 $post_blocks = self::migrate_woo_email_content_namespace( $post_blocks, $aliases_migrated );
645
646 return array(
647 // $post_blocks originates from parse_blocks() and our mutations only
648 // rewrite well-typed fields; serialize_blocks accepts the same shape.
649 // PHPStan can't follow the mutation chain, so the explicit ignore.
650 // @phpstan-ignore-next-line argument.type
651 'content' => serialize_blocks( $post_blocks ),
652 'structural_skipped' => $structural_skipped,
653 'aliases_migrated' => array_values( array_unique( $aliases_migrated ) ),
654 );
655 }
656
657 /**
658 * Walk the merged tree and rewrite every `wp:woo/email-content` block to
659 * the canonical `wp:woocommerce/email-content` form. Touches the
660 * `blockName` and the `wp-block-woo-email-content` CSS class in the
661 * block's `innerHTML` and each `innerContent` segment. The block's
662 * `attrs` and inner content are otherwise preserved.
663 *
664 * Targeted to a single known alias by design — this is not a general
665 * alias-migration framework. Add new entries here only when a real
666 * deprecated→canonical rename ships and we want it auto-migrated on
667 * apply.
668 *
669 * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
670 * @param string[] $migrated Names of aliases that were rewritten (out param, appended to).
671 *
672 * @return array<int|string, array<string, mixed>>
673 */
674 private static function migrate_woo_email_content_namespace( array $blocks, array &$migrated ): array {
675 $out = array();
676 foreach ( $blocks as $block ) {
677 if ( ! is_array( $block ) ) {
678 $out[] = $block;
679 continue;
680 }
681
682 if ( 'woo/email-content' === ( $block['blockName'] ?? null ) ) {
683 $block['blockName'] = 'woocommerce/email-content';
684
685 if ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) {
686 $block['innerHTML'] = str_replace(
687 'wp-block-woo-email-content',
688 'wp-block-woocommerce-email-content',
689 $block['innerHTML']
690 );
691 }
692
693 if ( isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
694 foreach ( $block['innerContent'] as $i => $segment ) {
695 if ( is_string( $segment ) ) {
696 $block['innerContent'][ $i ] = str_replace(
697 'wp-block-woo-email-content',
698 'wp-block-woocommerce-email-content',
699 $segment
700 );
701 }
702 }
703 }
704
705 $migrated[] = 'woo/email-content';
706 }//end if
707
708 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
709 $block['innerBlocks'] = self::migrate_woo_email_content_namespace( $block['innerBlocks'], $migrated );
710 }
711
712 $out[] = $block;
713 }//end foreach
714 return $out;
715 }
716
717 /**
718 * Replace the block at the given path with another block's content.
719 * Preserves the post block's `attrs` (no attribute-level apply in v1);
720 * copies the source block's `innerHTML`, `innerContent`, and
721 * `innerBlocks` over the target.
722 *
723 * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
724 * @param array<int|string> $path Index path through `parse_blocks` output.
725 * @param array<string, mixed> $source_block The block whose content to copy in.
726 *
727 * @return array<int|string, array<string, mixed>>
728 */
729 private static function replace_block_content_at_path( array $blocks, array $path, array $source_block ): array {
730 if ( empty( $path ) ) {
731 return $blocks;
732 }
733 return self::replace_recursive( $blocks, array_values( $path ), 0, $source_block );
734 }
735
736 /**
737 * Recursive worker for {@see self::replace_block_content_at_path()}.
738 *
739 * @param array<int|string, array<string, mixed>> $blocks Current level of the tree.
740 * @param array<int|string> $path Path indices.
741 * @param int $depth Current depth.
742 * @param array<string, mixed> $source_block Source block to copy content from.
743 *
744 * @return array<int|string, array<string, mixed>>
745 */
746 private static function replace_recursive( array $blocks, array $path, int $depth, array $source_block ): array {
747 $idx = (int) $path[ $depth ];
748 if ( ! isset( $blocks[ $idx ] ) ) {
749 return $blocks;
750 }
751
752 if ( count( $path ) - 1 === $depth ) {
753 $blocks[ $idx ]['innerHTML'] = $source_block['innerHTML'] ?? '';
754 $blocks[ $idx ]['innerContent'] = $source_block['innerContent'] ?? array();
755 $blocks[ $idx ]['innerBlocks'] = $source_block['innerBlocks'] ?? array();
756 return $blocks;
757 }
758
759 $inner = $blocks[ $idx ]['innerBlocks'] ?? array();
760 $blocks[ $idx ]['innerBlocks'] = self::replace_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $source_block );
761 return $blocks;
762 }
763
764 /**
765 * Insert a block at the equivalent position in the merged tree using
766 * core's path as a guide. Best-effort: if a parent in the path doesn't
767 * exist in the post tree, append at the closest level.
768 *
769 * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
770 * @param array<int|string> $path Core-side index path of the block to insert.
771 * @param array<string, mixed> $new_block The block to insert.
772 *
773 * @return array<int|string, array<string, mixed>>
774 */
775 private static function insert_block_at_path( array $blocks, array $path, array $new_block ): array {
776 if ( empty( $path ) ) {
777 return $blocks;
778 }
779 return self::insert_recursive( $blocks, array_values( $path ), 0, $new_block );
780 }
781
782 /**
783 * Recursive worker for {@see self::insert_block_at_path()}.
784 *
785 * @param array<int|string, array<string, mixed>> $blocks Current level.
786 * @param array<int|string> $path Path indices.
787 * @param int $depth Current depth.
788 * @param array<string, mixed> $new_block Block to insert.
789 *
790 * @return array<int|string, array<string, mixed>>
791 */
792 private static function insert_recursive( array $blocks, array $path, int $depth, array $new_block ): array {
793 $idx = (int) $path[ $depth ];
794
795 if ( count( $path ) - 1 === $depth ) {
796 $insert_at = max( 0, min( $idx, count( $blocks ) ) );
797 array_splice( $blocks, $insert_at, 0, array( $new_block ) );
798 return $blocks;
799 }
800
801 if ( ! isset( $blocks[ $idx ] ) ) {
802 // The parent on the core side doesn't exist in the post tree —
803 // fall back to appending at this level so the block isn't lost.
804 $blocks[] = $new_block;
805 return $blocks;
806 }
807
808 $inner = $blocks[ $idx ]['innerBlocks'] ?? array();
809 $blocks[ $idx ]['innerBlocks'] = self::insert_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $new_block );
810 return $blocks;
811 }
812
813 /**
814 * Walk a parsed block tree along a path and return the block at that
815 * path, or null if any segment is missing.
816 *
817 * @param array<int|string, array<string, mixed>> $blocks Parsed block tree.
818 * @param array<int|string> $path Index path.
819 *
820 * @return array<string, mixed>|null
821 */
822 private static function block_at_path( array $blocks, array $path ): ?array {
823 if ( empty( $path ) ) {
824 return null;
825 }
826 $current = $blocks;
827 $last = count( $path ) - 1;
828 foreach ( array_values( $path ) as $depth => $idx ) {
829 $idx = (int) $idx;
830 if ( ! isset( $current[ $idx ] ) || ! is_array( $current[ $idx ] ) ) {
831 return null;
832 }
833 if ( $depth === $last ) {
834 return $current[ $idx ];
835 }
836 $inner = $current[ $idx ]['innerBlocks'] ?? array();
837 $current = is_array( $inner ) ? $inner : array();
838 }
839 return null;
840 }
841
842 /**
843 * Whether the given post-alias-normalized block name is a structural
844 * wrapper (matches the same set RSM-142 uses for nest detection).
845 *
846 * @param string $name Normalized block name (e.g. `core/group`).
847 */
848 private static function is_structural_block( string $name ): bool {
849 return in_array(
850 $name,
851 array( 'core/group', 'core/columns', 'core/column', 'core/row' ),
852 true
853 );
854 }
855
856 /**
857 * Whitespace-normalize block markup for semantic comparison. Trims and
858 * collapses runs of whitespace adjacent to tag boundaries — covers the
859 * leading/trailing newlines and the spaces inside `<div> ##WOO_CONTENT## </div>`
860 * that `serialize_blocks()` can't reproduce from a hand-authored PHP template.
861 *
862 * @param string $content Block markup.
863 */
864 private static function normalize_for_comparison( string $content ): string {
865 $content = trim( $content );
866 $content = (string) preg_replace( '/>\s+/', '>', $content );
867 $content = (string) preg_replace( '/\s+</', '<', $content );
868 return $content;
869 }
870
871 /**
872 * Stable string key for a path array, used as the choice-map key.
873 *
874 * @param array<int|string> $path Path indices.
875 */
876 private static function path_key( array $path ): string {
877 $encoded = wp_json_encode( array_values( $path ) );
878 return false === $encoded ? '[]' : $encoded;
879 }
880
881 /**
882 * Return the logger instance, lazily creating it the first time.
883 */
884 private static function get_logger(): Email_Editor_Logger_Interface {
885 if ( null === self::$logger ) {
886 self::$logger = new Logger( wc_get_logger() );
887 }
888 return self::$logger;
889 }
890 }
891