woocommerce
/
src
/
Internal
/
EmailEditor
/
WCTransactionalEmails
/
WCEmailTemplateSelectiveApplier.php
WCEmailTemplateSelectiveApplier.php
| 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 |