PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / import / class-wdkit-ai-content.php

class-wdkit-ai-content.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/import/class-wdkit-ai-content.php

2,723 lines 89.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI content merge layer (PHP).
4 *
5 * This is the PHP counterpart of the merge logic that today lives only in the browser
6 * (import_loader.js): get_ai_object, replace_new_content, replace_guten_new_content and
7 * their helpers. The browser copy is untouched and remains the only path the wizard uses.
8 * This exists so the PHP runner — and, in Phase 2, a server-driven import — can perform the
9 * same merge without a browser.
10 *
11 * ── The data model ──────────────────────────────────────────────────────────
12 *
13 * Templates carry AI *instructions* on widget settings under `wdkitai_*` keys, e.g.
14 * "wdkitai_title": "give 6 word heading text describing the clinic"
15 * Sections are marked by a container whose settings have
16 * wdkitai_container_setting === 'yes' and wdkitai_section_types
17 * (or 'wdkit_custom' plus wdkitai_custom_type / wdkitai_custom_description).
18 *
19 * extract_ai_object() walks that tree and produces the request shape the AI endpoint
20 * expects: section => { information, elements: [ { id, wdkitai_* } ] }.
21 *
22 * The AI answers with, per element, a list of key => value **arrays** — always arrays,
23 * because one instruction can ask for N strings (three testimonials, five service titles).
24 * That answer is this class's canonical contract:
25 *
26 * [ 'version' => 1,
27 * 'builder' => 'elementor'|'gutenberg',
28 * 'elements' => [ [ 'id' => '<element id|block_id>',
29 * 'data' => [ [ '<key>' => [ 'v1', 'v2' ] ], ... ] ] ] ]
30 *
31 * That is deliberately the same `txt_array` shape the browser already consumes, so the
32 * existing flow, a future website flow, a chat flow and an MCP agent can all feed one
33 * merge implementation.
34 *
35 * ── The one rule that matters ───────────────────────────────────────────────
36 *
37 * A merge only ever writes where the template already has that key. No AI value, or a key
38 * the template does not use, leaves the content byte-for-byte unchanged. That is what makes
39 * running the merge safe when generation is partial or missing.
40 *
41 * @package Wdesignkit
42 * @since 2.6.5
43 */
44
45 if ( ! defined( 'ABSPATH' ) ) {
46 exit;
47 }
48
49 if ( ! class_exists( 'Wdkit_Ai_Content' ) ) {
50
51 /**
52 * AI content extraction and merge.
53 */
54 class Wdkit_Ai_Content {
55
56 /**
57 * Contract version carried on every payload.
58 */
59 const CONTRACT_VERSION = 1;
60
61 /**
62 * `wdkitai_*` keys that describe the template rather than requesting copy. They are
63 * never sent to the AI. Mirrors the exclusion list in import_loader.js.
64 *
65 * @var string[]
66 */
67 private static $meta_keys = array(
68 'wdkitai_unique_id',
69 'wdkitai_meta_info',
70 'wdkitai_admin_detail_order',
71 'wdkitai_image_type',
72 'wdkitai_image_description_type',
73 'wdkitai_image_generic_type',
74 'wdkitai_image_description',
75 'wdkitai_social_icon_order',
76 'wdkitai_site_logo',
77 'wdkitai_team_library_type',
78 );
79
80 /**
81 * Gutenberg keys that hold a link object rather than plain text.
82 *
83 * @var string[]
84 */
85 private static $gutenberg_link_keys = array(
86 'descurl',
87 'IBoxLink',
88 'link',
89 'imageStore',
90 'svgStore',
91 'tLink',
92 'btnLink',
93 'bLink',
94 );
95
96 /**
97 * Gutenberg keys that may be written straight onto the block object when the key is
98 * found nowhere else. Mirrors `allowedKeys` in import_loader.js.
99 *
100 * @var string[]
101 */
102 private static $gutenberg_allowed_keys = array(
103 'exTitle',
104 'exproCnt',
105 'exbtxt',
106 'authorName',
107 'content',
108 );
109
110 /**
111 * Font Awesome 5 icons Elementor ships, per style, read once per request.
112 *
113 * @var array<string,array<string,bool>|null>
114 */
115 private static $fa5_icons = array();
116
117 /**
118 * The AI's icon answer as an Elementor Font Awesome 5 value, or null to keep the kit's own.
119 *
120 * Elementor and The Plus Addons load Font Awesome 5, whose classes are `fas fa-x`. The AI
121 * often answers in Font Awesome 6 form (`fa-solid fa-x`) or names an icon FA5 does not
122 * have, and either one rendered as an empty box or a broken glyph in place of the kit's
123 * icon (ClickUp 14ynqxz2t0y). Rewritten to the FA5 prefix, and checked against
124 * Elementor's own icon list when it is available.
125 *
126 * @param mixed $value AI answer, e.g. "fas fa-pizza-slice" or "fa-solid fa-pizza-slice".
127 * @return array{value:string,library:string}|null
128 */
129 private static function font_awesome_5_icon( $value ) {
130 $styles = array(
131 'fas' => 'solid',
132 'fa-solid' => 'solid',
133 'far' => 'regular',
134 'fa-regular' => 'regular',
135 'fab' => 'brands',
136 'fa-brands' => 'brands',
137 );
138 $prefix = array(
139 'solid' => 'fas',
140 'regular' => 'far',
141 'brands' => 'fab',
142 );
143
144 $style = 'solid';
145 $name = '';
146
147 foreach ( preg_split( '/\s+/', strtolower( trim( (string) $value ) ) ) as $token ) {
148 if ( isset( $styles[ $token ] ) ) {
149 $style = $styles[ $token ];
150 } elseif ( '' === $name && 0 === strpos( $token, 'fa-' ) && preg_match( '/^fa-[a-z0-9-]+$/', $token ) ) {
151 $name = substr( $token, 3 );
152 }
153 }
154
155 if ( '' === $name ) {
156 return null;
157 }
158
159 $known = self::fa5_icon_names( $style );
160
161 if ( null !== $known && ! isset( $known[ $name ] ) ) {
162 return null;
163 }
164
165 return array(
166 'value' => $prefix[ $style ] . ' fa-' . $name,
167 'library' => 'fa-' . $style,
168 );
169 }
170
171 /**
172 * Icon names in one Font Awesome 5 style, from Elementor's bundled JSON.
173 *
174 * @param string $style solid|regular|brands.
175 * @return array<string,bool>|null Null when Elementor's list is not available - then any
176 * well-formed name is accepted.
177 */
178 private static function fa5_icon_names( $style ) {
179 if ( array_key_exists( $style, self::$fa5_icons ) ) {
180 return self::$fa5_icons[ $style ];
181 }
182
183 self::$fa5_icons[ $style ] = null;
184
185 if ( ! defined( 'ELEMENTOR_PATH' ) ) {
186 return null;
187 }
188
189 $file = ELEMENTOR_PATH . 'assets/lib/font-awesome/json/' . $style . '.json';
190
191 if ( ! is_readable( $file ) ) {
192 return null;
193 }
194
195 $json = json_decode( (string) file_get_contents( $file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
196
197 if ( is_array( $json ) && ! empty( $json['icons'] ) && is_array( $json['icons'] ) ) {
198 self::$fa5_icons[ $style ] = array_fill_keys( array_keys( $json['icons'] ), true );
199 }
200
201 return self::$fa5_icons[ $style ];
202 }
203
204 /*
205 |--------------------------------------------------------------------------
206 | JS parity helpers
207 |--------------------------------------------------------------------------
208 */
209
210 /**
211 * JavaScript truthiness.
212 *
213 * This is not pedantry. The merge gates on `if ( element.settings[ key ] )`, and JS and
214 * PHP disagree on exactly the values that show up in template settings: an empty array
215 * or empty object is truthy in JS but falsy in PHP, and the string '0' is truthy in JS
216 * but falsy in PHP. Using PHP truthiness here would silently send some replacements
217 * down the wrong branch.
218 *
219 * @param mixed $value Value to test.
220 * @return bool
221 */
222 /**
223 * The visitor's social URLs, keyed by network, for the run in progress.
224 *
225 * Set by social_icon_values() and read by apply_elementor_value(), which is the only
226 * place that can see a repeater ROW and therefore which network that row actually is.
227 *
228 * Why it is needed: the values list is built in the order the template's
229 * `wdkitai_social_icon_order` declares, and written into the widget's rows by position.
230 * When the two disagree - a kit whose footer has Facebook, Instagram, WhatsApp, X while
231 * the order names only the three networks the wizard collects - row 3 (WhatsApp) took
232 * the URL meant for X, so the WhatsApp icon linked to x.com (ClickUp 14ynqxyykay).
233 *
234 * @var array<string,string>
235 */
236 private static $social_by_network = array();
237
238 /**
239 * Which network a social repeater row is, read off the row itself.
240 *
241 * Covers the shapes the kits actually use: TPAE's `pt_plus_social_icons` ("fa-facebook"),
242 * Elementor's own `social_icon` ("fab fa-facebook"), and a plain `social` string.
243 *
244 * @since 2.7.3
245 *
246 * @param array $row Repeater row.
247 * @return string Network name, or '' when the row does not say.
248 */
249 private static function row_network( $row ) {
250 $raw = '';
251
252 foreach ( array( 'pt_plus_social_icons', 'social', 'social_icon', 'icon' ) as $key ) {
253 if ( ! isset( $row[ $key ] ) ) {
254 continue;
255 }
256
257 $candidate = is_array( $row[ $key ] ) ? ( $row[ $key ]['value'] ?? '' ) : $row[ $key ];
258
259 if ( is_string( $candidate ) && '' !== trim( $candidate ) ) {
260 $raw = $candidate;
261 break;
262 }
263 }
264
265 if ( '' === $raw ) {
266 return '';
267 }
268
269 /* "fab fa-facebook-f" -> "facebook". The suffix on brand icons ( -f, -square,
270 * -in ) is a glyph variant, not a different network. */
271 $parts = preg_split( '/\s+/', strtolower( trim( $raw ) ) );
272 $last = (string) end( $parts );
273 $last = preg_replace( '/^fa[bsrl]?-/', '', $last );
274 $last = preg_replace( '/-(f|in|square|circle|alt)$/', '', (string) $last );
275
276 $aliases = array(
277 'x' => 'twitter',
278 'x-twitter' => 'twitter',
279 'twitter' => 'twitter',
280 'fb' => 'facebook',
281 'linked-in' => 'linkedin',
282 'yt' => 'youtube',
283 );
284
285 return isset( $aliases[ $last ] ) ? $aliases[ $last ] : (string) $last;
286 }
287
288 private static function js_truthy( $value ) {
289 if ( is_array( $value ) ) {
290 return true;
291 }
292
293 if ( is_null( $value ) ) {
294 return false;
295 }
296
297 if ( is_bool( $value ) ) {
298 return $value;
299 }
300
301 if ( is_int( $value ) || is_float( $value ) ) {
302 return 0 != $value; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
303 }
304
305 if ( is_string( $value ) ) {
306 return '' !== $value;
307 }
308
309 return (bool) $value;
310 }
311
312 /**
313 * Is this array a JSON list (sequential integer keys) rather than an object?
314 *
315 * json_decode(..., true) flattens both to PHP arrays, but the merge treats a repeater
316 * list and a settings object very differently.
317 *
318 * @param array $value Array to test.
319 * @return bool
320 */
321 private static function is_list( $value ) {
322 if ( ! is_array( $value ) ) {
323 return false;
324 }
325
326 if ( array() === $value ) {
327 return false;
328 }
329
330 return array_keys( $value ) === range( 0, count( $value ) - 1 );
331 }
332
333 /**
334 * Is this a link worth writing over the template's own?
335 *
336 * The AI returns one entry per repeater item and legitimately has nothing to say for
337 * some — a business with no X account. Writing an empty string into a link field is
338 * worse than leaving the template value: several blocks treat "no link" as "render
339 * nothing", so the icon disappears entirely.
340 *
341 * @param mixed $value Candidate link.
342 * @return bool
343 */
344 private static function is_usable_link( $value ) {
345 return is_string( $value ) && '' !== trim( $value );
346 }
347
348 /*
349 |--------------------------------------------------------------------------
350 | 1. Extraction — build the AI request from a template
351 |--------------------------------------------------------------------------
352 */
353
354 /**
355 * Walk an Elementor tree and collect the AI instructions, grouped by section.
356 *
357 * Port of get_ai_object() / extractWdkitaiWidgets() in import_loader.js.
358 *
359 * @param array $elements Decoded Elementor element tree.
360 * @param array $section_labels Optional section => default description map (the JS
361 * `section_array`), used when a section has no custom
362 * description of its own.
363 * @return array{sectionMap:array,detailsection:array}
364 */
365 public static function extract_ai_object( $elements, $section_labels = array() ) {
366 $section_map = array();
367 $detail_section = array();
368
369 self::traverse_for_instructions( $elements, null, $section_labels, $section_map, $detail_section );
370
371 return array(
372 'sectionMap' => $section_map,
373 'detailsection' => $detail_section,
374 );
375 }
376
377 /**
378 * Recursive worker for extract_ai_object().
379 *
380 * @param mixed $elements Element list.
381 * @param string|null $current Section currently in scope.
382 * @param array $section_labels Section => description.
383 * @param array $section_map Accumulator (by reference).
384 * @param array $detail_section Accumulator (by reference).
385 * @return void
386 */
387 private static function traverse_for_instructions( $elements, $current, $section_labels, &$section_map, &$detail_section ) {
388 if ( ! is_array( $elements ) ) {
389 return;
390 }
391
392 foreach ( $elements as $element ) {
393 if ( ! is_array( $element ) ) {
394 continue;
395 }
396
397 $settings = isset( $element['settings'] ) && is_array( $element['settings'] ) ? $element['settings'] : array();
398
399 /** a container that opens a named section */
400 if ( ! empty( $settings ) && isset( $settings['wdkitai_container_setting'] ) && 'yes' === $settings['wdkitai_container_setting'] && ! empty( $settings['wdkitai_section_types'] ) ) {
401 $section_description = null;
402
403 if ( 'wdkit_custom' === $settings['wdkitai_section_types'] && ! empty( $settings['wdkitai_custom_type'] ) ) {
404 $current = (string) $settings['wdkitai_custom_type'];
405 $section_description = isset( $settings['wdkitai_custom_description'] ) ? $settings['wdkitai_custom_description'] : null;
406 } else {
407 $current = (string) $settings['wdkitai_section_types'];
408 }
409
410 if ( ! isset( $section_map[ $current ] ) ) {
411 $section_map[ $current ] = array(
412 'information' => ! empty( $section_description )
413 ? $section_description
414 : ( isset( $section_labels[ $current ] ) ? $section_labels[ $current ] : '' ),
415 'elements' => array(),
416 );
417
418 $detail_section[ $current ] = array( 'elements' => array() );
419 }
420 }
421
422 /** a widget carrying instructions */
423 if ( isset( $element['elType'] ) && 'widget' === $element['elType'] && ! empty( $settings ) ) {
424 $all_fields = array();
425
426 foreach ( $settings as $key => $value ) {
427 if ( 0 === strpos( (string) $key, 'wdkitai_' ) ) {
428 $all_fields[ $key ] = $value;
429 }
430 }
431
432 if ( ! empty( $all_fields ) && null !== $current ) {
433 $filtered = array( 'id' => isset( $element['id'] ) ? $element['id'] : '' );
434
435 foreach ( $all_fields as $key => $value ) {
436 if ( in_array( $key, self::$meta_keys, true ) ) {
437 continue;
438 }
439
440 /** paired "_replace" keys hold the previous value, not an instruction */
441 if ( '_replace' === substr( (string) $key, -8 ) ) {
442 continue;
443 }
444
445 $filtered[ $key ] = $value;
446 }
447
448 $detail = $all_fields;
449 $detail['id'] = isset( $element['id'] ) ? $element['id'] : '';
450
451 /** id alone is not an instruction — only record a widget that asks for something */
452 if ( count( $filtered ) > 1 ) {
453 $section_map[ $current ]['elements'][] = $filtered;
454 }
455
456 $detail_section[ $current ]['elements'][] = $detail;
457 }
458 }
459
460 if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) {
461 self::traverse_for_instructions( $element['elements'], $current, $section_labels, $section_map, $detail_section );
462 }
463 }
464 }
465
466 /*
467 |--------------------------------------------------------------------------
468 | 2. Contract
469 |--------------------------------------------------------------------------
470 */
471
472 /**
473 * Normalise an AI answer into the canonical contract.
474 *
475 * Accepts either the canonical envelope or the bare `txt_array` list the browser
476 * already uses, so an existing response can be fed straight in. Anything malformed is
477 * dropped rather than guessed at — a bad element is skipped, not merged.
478 *
479 * @param mixed $raw Decoded AI answer, or a JSON string.
480 * @param string $builder 'elementor'|'gutenberg'.
481 * @return array Canonical payload.
482 */
483 /**
484 * Decode a JSON answer that came from the model, tolerating what models actually emit.
485 *
486 * The cloud returns the answer as a JSON string. Strict json_decode() is right to reject
487 * some of what comes back, and rejecting it is what silently cost pages their AI copy: a
488 * 21KB answer for the Zion landing page failed with "Control character error, possibly
489 * incorrectly encoded", both converters were then handed null, and the page kept its
490 * template text with nothing logged. Intermittent by nature — it depends on whether the
491 * model happened to put a literal newline or tab inside a string value that run.
492 *
493 * Two repairs, both conservative, both applied only after a strict parse has already
494 * failed:
495 *
496 * 1. Markdown code fences around the object, which models add unprompted.
497 * 2. Raw control characters INSIDE string literals, escaped to their JSON forms. The
498 * regex walks complete `"…"` literals (honouring backslash escapes) so structural
499 * whitespace between tokens is never touched — only bytes that are illegal where
500 * they sit.
501 *
502 * Anything still unparseable after that is a genuinely broken answer and returns null, as
503 * before. This never makes a valid answer parse differently: the strict attempt wins first.
504 *
505 * @since 2.7.2
506 *
507 * @param string $raw Raw answer.
508 * @return array|null Decoded array, or null.
509 */
510 public static function decode_answer( $raw ) {
511 if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
512 return null;
513 }
514
515 $decoded = json_decode( $raw, true );
516
517 if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
518 return $decoded;
519 }
520
521 $clean = trim( $raw );
522
523 /* ```json … ``` */
524 $clean = preg_replace( '/^```[a-zA-Z]*\s*/', '', $clean );
525 $clean = preg_replace( '/\s*```$/', '', $clean );
526
527 /* Two repair strategies, tried in order, first one that parses wins.
528 *
529 * They differ only in what a RAW newline inside a string literal means, and the answers
530 * genuinely contain both cases:
531 *
532 * 'escape' — the value really does span lines, so the newline becomes \n. Right for a
533 * description the model wrote across two lines.
534 * 'close' — the value was truncated and its closing quote never arrived, e.g.
535 * `"wdkitai_icon_description_replace ": "selected_icon` followed by a
536 * newline and `}`. Escaping there swallows the rest of the document into
537 * the string; closing the quote at the line end recovers it.
538 *
539 * Guessing between them is unnecessary — parsing is the test. Both passes also drop
540 * trailing commas (`[ "a", ]`), which is the single most common defect in these answers
541 * and on its own makes an otherwise perfect 22KB document unparseable.
542 *
543 * PHP reports these as "Syntax error" on some runs and "Control character error" on
544 * others, which is why the failure reads like an encoding problem and is not one. */
545 foreach ( array( 'escape', 'close' ) as $strategy ) {
546 $repaired = self::repair_json( $clean, $strategy );
547 $decoded = json_decode( $repaired, true );
548
549 if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
550 return $decoded;
551 }
552 }
553
554 return null;
555 }
556
557 /**
558 * One repair pass over a nearly-valid JSON document.
559 *
560 * A single linear scan rather than a regex: the pattern for quoted runs is ambiguous
561 * enough that PCRE exhausts its JIT stack on a real answer (measured: null and error 6 on
562 * 15KB), and a preg_* returning null would silently leave the text unrepaired — precisely
563 * the failure this exists to prevent.
564 *
565 * `$in_string` tracks whether the cursor sits inside a "…" literal and `$escaped` skips the
566 * character after a backslash, so an escaped quote never ends a string early and nothing
567 * outside a literal is rewritten.
568 *
569 * @since 2.7.2
570 *
571 * @param string $clean Trimmed, fence-stripped source.
572 * @param string $strategy 'escape' or 'close' — see decode_answer().
573 * @return string
574 */
575 private static function repair_json( $clean, $strategy ) {
576 $escapes = array(
577 "\n" => '\\n',
578 "\r" => '\\r',
579 "\t" => '\\t',
580 );
581
582 $repaired = '';
583 $in_string = false;
584 $escaped = false;
585 $length = strlen( $clean );
586
587 for ( $i = 0; $i < $length; $i++ ) {
588 $char = $clean[ $i ];
589
590 if ( $escaped ) {
591 $repaired .= $char;
592 $escaped = false;
593 continue;
594 }
595
596 if ( $in_string && '\\' === $char ) {
597 $repaired .= $char;
598 $escaped = true;
599 continue;
600 }
601
602 if ( '"' === $char ) {
603 $in_string = ! $in_string;
604 $repaired .= $char;
605 continue;
606 }
607
608 if ( $in_string ) {
609 if ( "\n" === $char || "\r" === $char ) {
610 if ( 'close' === $strategy ) {
611 /* The quote never arrived — supply it and hand the newline back to the
612 * document as ordinary whitespace. */
613 $repaired .= '"' . $char;
614 $in_string = false;
615
616 continue;
617 }
618
619 $repaired .= $escapes[ $char ];
620
621 continue;
622 }
623
624 if ( $char < ' ' ) {
625 $repaired .= isset( $escapes[ $char ] )
626 ? $escapes[ $char ]
627 : sprintf( '\\u%04x', ord( $char ) );
628
629 continue;
630 }
631
632 $repaired .= $char;
633 continue;
634 }
635
636 /* Outside a string: a comma whose next meaningful character closes the container is
637 * a trailing comma, and is dropped. */
638 if ( ',' === $char ) {
639 $j = $i + 1;
640
641 while ( $j < $length && ( ' ' === $clean[ $j ] || "\t" === $clean[ $j ] || "\n" === $clean[ $j ] || "\r" === $clean[ $j ] ) ) {
642 ++$j;
643 }
644
645 if ( $j < $length && ( ']' === $clean[ $j ] || '}' === $clean[ $j ] ) ) {
646 continue;
647 }
648 }
649
650 $repaired .= $char;
651 }
652
653 return $repaired;
654 }
655
656 public static function normalize_payload( $raw, $builder = 'elementor' ) {
657 if ( is_string( $raw ) ) {
658 $raw = self::decode_answer( $raw );
659 }
660
661 $builder = ( 'gutenberg' === $builder ) ? 'gutenberg' : 'elementor';
662
663 $payload = array(
664 'version' => self::CONTRACT_VERSION,
665 'builder' => $builder,
666 'elements' => array(),
667 );
668
669 if ( ! is_array( $raw ) ) {
670 return $payload;
671 }
672
673 /** envelope form */
674 $elements = $raw;
675
676 if ( isset( $raw['elements'] ) && is_array( $raw['elements'] ) ) {
677 $elements = $raw['elements'];
678
679 if ( isset( $raw['builder'] ) && in_array( $raw['builder'], array( 'elementor', 'gutenberg' ), true ) ) {
680 $payload['builder'] = $raw['builder'];
681 }
682 }
683
684 if ( ! self::is_list( $elements ) ) {
685 return $payload;
686 }
687
688 foreach ( $elements as $entry ) {
689 if ( ! is_array( $entry ) || ! isset( $entry['id'] ) || ! isset( $entry['data'] ) ) {
690 continue;
691 }
692
693 $id = (string) $entry['id'];
694
695 if ( '' === $id || ! is_array( $entry['data'] ) ) {
696 continue;
697 }
698
699 $pairs = array();
700
701 foreach ( $entry['data'] as $pair ) {
702 if ( ! is_array( $pair ) ) {
703 continue;
704 }
705
706 $clean = array();
707
708 foreach ( $pair as $key => $values ) {
709 /** every value is a list, even a single string */
710 $clean[ (string) $key ] = is_array( $values ) ? array_values( $values ) : array( $values );
711 }
712
713 if ( ! empty( $clean ) ) {
714 $pairs[] = $clean;
715 }
716 }
717
718 if ( ! empty( $pairs ) ) {
719 $payload['elements'][] = array(
720 'id' => $id,
721 'data' => $pairs,
722 );
723 }
724 }
725
726 return $payload;
727 }
728
729 /**
730 * Index a canonical payload by element id for O(1) lookup during the walk.
731 *
732 * @param array $payload Canonical payload.
733 * @return array<string,array>
734 */
735 private static function index_by_id( $payload ) {
736 $index = array();
737
738 if ( empty( $payload['elements'] ) || ! is_array( $payload['elements'] ) ) {
739 return $index;
740 }
741
742 foreach ( $payload['elements'] as $entry ) {
743 $index[ (string) $entry['id'] ] = $entry['data'];
744 }
745
746 return $index;
747 }
748
749 /*
750 |--------------------------------------------------------------------------
751 | 3. Elementor merge
752 |--------------------------------------------------------------------------
753 */
754
755 /**
756 * Merge AI copy into an Elementor element tree.
757 *
758 * Port of replace_new_content() in import_loader.js, including its guard rails: an
759 * icon is only replaced when the AI actually returned one, and a key absent from the
760 * widget's own settings is looked for inside repeaters and nested setting objects
761 * before being given up on.
762 *
763 * @param array $tree Decoded Elementor tree (by reference).
764 * @param array $payload Canonical payload.
765 * @return int Number of element matches applied.
766 */
767 public static function merge_elementor( &$tree, $payload ) {
768 $index = self::index_by_id( $payload );
769
770 if ( empty( $index ) || ! is_array( $tree ) ) {
771 return 0;
772 }
773
774 $applied = 0;
775
776 self::walk_elementor( $tree, $index, $applied );
777
778 return $applied;
779 }
780
781 /**
782 * @param array $elements Element list (by reference).
783 * @param array $index id => data pairs.
784 * @param int $applied Counter (by reference).
785 * @return void
786 */
787 private static function walk_elementor( &$elements, $index, &$applied ) {
788 if ( ! is_array( $elements ) ) {
789 return;
790 }
791
792 foreach ( $elements as &$element ) {
793 if ( ! is_array( $element ) ) {
794 continue;
795 }
796
797 $is_widget = isset( $element['elType'] ) && 'widget' === $element['elType'];
798 $id = isset( $element['id'] ) ? (string) $element['id'] : '';
799
800 if ( $is_widget && isset( $element['settings'] ) && is_array( $element['settings'] ) && '' !== $id && isset( $index[ $id ] ) ) {
801 foreach ( self::spill_extra_values( $element['settings'], $index[ $id ] ) as $pair ) {
802 foreach ( $pair as $key => $values ) {
803 self::apply_elementor_value( $element['settings'], $key, $values );
804 }
805 }
806
807 ++$applied;
808 }
809
810 if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) {
811 self::walk_elementor( $element['elements'], $index, $applied );
812 }
813
814 /* AFTER the children, deliberately: the item containers' own text widgets were
815 * merged in the recursion just above, and the paired answer has to be written
816 * over them, not under them. See route_to_nested_items(). */
817 if ( $is_widget && '' !== $id && isset( $index[ $id ] ) ) {
818 self::route_to_nested_items( $element, $index[ $id ] );
819 }
820 }
821
822 unset( $element );
823 }
824
825 /**
826 * Give a widget's unanswered text field the extra value the model put under its sibling.
827 *
828 * A tp-heading-title carries two instructions - `wdkitai_title` -> title and
829 * `wdkitai_subtitle` -> sub_title - and the model sometimes answers both under the first
830 * key: `wdkitai_title: ["Routine Checkup", "Ensuring your dental health with regular
831 * examinations."]`. A plain-text setting takes only the first value (as in
832 * replace_new_content() on ai-template-latest), so the heading changed and the subtitle
833 * kept the template's text - on a Taj Bakery import for a dental clinic, three pricing
834 * cards read "Routine Checkup / Peach cupcake with vanilla frosting".
835 *
836 * Narrow on purpose. The spare values only go to a field that
837 * - the widget itself declares an instruction for (`wdkitai_X` + `wdkitai_X_replace`),
838 * - holds plain text (a string setting - never a link, image, icon or repeater),
839 * - and got no value of its own in this answer,
840 * in the order the widget declares them, and only from a key whose own target is also
841 * plain text. A widget that asked for one field is left exactly as before.
842 *
843 * @since 2.7.2
844 *
845 * @param array $settings Widget settings.
846 * @param array $pairs This widget's payload data pairs.
847 * @return array The pairs, with any spilled values added.
848 */
849 private static function spill_extra_values( $settings, $pairs ) {
850 $targets = array();
851
852 foreach ( $settings as $key => $instruction ) {
853 $key = (string) $key;
854
855 if ( 0 !== strpos( $key, 'wdkitai_' ) || '_replace' === substr( rtrim( $key ), -8 ) || ! is_string( $instruction ) || '' === trim( $instruction ) ) {
856 continue;
857 }
858
859 foreach ( array( $key . '_replace', $key . '_replace ' ) as $map_key ) {
860 if ( ! empty( $settings[ $map_key ] ) && is_string( $settings[ $map_key ] ) ) {
861 $target = trim( $settings[ $map_key ] );
862
863 if ( '' !== $target && isset( $settings[ $target ] ) && is_string( $settings[ $target ] ) && ! in_array( $target, $targets, true ) ) {
864 $targets[] = $target;
865 }
866
867 break;
868 }
869 }
870 }
871
872 if ( count( $targets ) < 2 ) {
873 return $pairs;
874 }
875
876 $answered = array();
877
878 foreach ( (array) $pairs as $pair ) {
879 foreach ( (array) $pair as $key => $values ) {
880 if ( is_array( $values ) && array_filter( $values, 'is_string' ) ) {
881 $answered[ (string) $key ] = true;
882 }
883 }
884 }
885
886 $missing = array_values( array_diff( $targets, array_keys( $answered ) ) );
887
888 if ( empty( $missing ) ) {
889 return $pairs;
890 }
891
892 foreach ( (array) $pairs as $pair ) {
893 foreach ( (array) $pair as $key => $values ) {
894 if ( ! in_array( (string) $key, $targets, true ) || ! is_array( $values ) ) {
895 continue;
896 }
897
898 foreach ( array_slice( array_values( $values ), 1 ) as $extra ) {
899 if ( empty( $missing ) ) {
900 break 3;
901 }
902
903 if ( is_string( $extra ) && '' !== trim( $extra ) ) {
904 $pairs[] = array( array_shift( $missing ) => array( $extra ) );
905 }
906 }
907 }
908 }
909
910 return $pairs;
911 }
912
913 /**
914 * Widgets whose items keep their body in child containers rather than in a repeater row.
915 *
916 * @var string[]
917 */
918 private static $nested_item_widgets = array( 'nested-accordion', 'nested-tabs' );
919
920 /**
921 * Put an item-level answer the widget itself has no field for into each item's text.
922 *
923 * Elementor's classic Accordion kept each answer in the `tab_content` field of its
924 * repeater, and kit templates still ask the AI for `tab_content`. The Nested Accordion
925 * that replaced it keeps each answer in a text widget INSIDE the item's own container,
926 * and has no `tab_content` anywhere — so apply_elementor_value() found no home for the
927 * answers and they were silently dropped. What showed instead was the inner text
928 * widget's separate instruction, which the model sometimes answers with a three-word
929 * heading: an FAQ reading "Common Questions About Our Services -> Shipping Logistics
930 * Explained". The dropped answers were the good ones — full sentences, and generated in
931 * the same list as the questions, so each one matches its question.
932 *
933 * ai-template-latest does this for Gutenberg (`accor_inner_key` in get_gutenberg_obj)
934 * and never did for Elementor; this is the Elementor half.
935 *
936 * Conservative on purpose:
937 * - only for a value the widget genuinely cannot place (not a direct setting, not a
938 * key on any repeater row) — `item_title` and friends are never touched;
939 * - only when there is a value for EVERY item. A list shorter than the items means the
940 * model under-delivered, and mixing one routed answer with the rest from elsewhere
941 * would be worse than leaving each item's own text as it merged. On the kit's FAQ
942 * page, for example, the accordion-level answer is a single two-word value while the
943 * inner widgets carry full sentences, and those are left alone.
944 *
945 * @since 2.7.2
946 *
947 * @param array $element Widget element (by reference).
948 * @param array $pairs This widget's payload data pairs.
949 * @return void
950 */
951 private static function route_to_nested_items( &$element, $pairs ) {
952 $type = isset( $element['widgetType'] ) ? (string) $element['widgetType'] : '';
953
954 if ( ! in_array( $type, self::$nested_item_widgets, true ) ) {
955 return;
956 }
957
958 if ( empty( $element['elements'] ) || ! is_array( $element['elements'] ) || empty( $element['settings'] ) || ! is_array( $element['settings'] ) ) {
959 return;
960 }
961
962 $item_count = count( $element['elements'] );
963
964 foreach ( (array) $pairs as $pair ) {
965 foreach ( (array) $pair as $key => $values ) {
966 if ( self::is_placeable( $element['settings'], $key ) ) {
967 continue;
968 }
969
970 $values = is_array( $values ) ? array_values( $values ) : array();
971
972 if ( count( $values ) < $item_count ) {
973 continue;
974 }
975
976 foreach ( $element['elements'] as $item_index => &$item ) {
977 $value = isset( $values[ $item_index ] ) ? $values[ $item_index ] : null;
978
979 if ( is_string( $value ) && '' !== trim( $value ) && is_array( $item ) ) {
980 self::write_first_text_editor( $item, $value );
981 }
982 }
983
984 unset( $item );
985 }
986 }
987 }
988
989 /**
990 * Could apply_elementor_value() put this key anywhere on these settings?
991 *
992 * The same two places it looks: a truthy setting of that name, or a truthy key of that
993 * name on a row of any repeater.
994 *
995 * @param array $settings Widget settings.
996 * @param string $key Setting key.
997 * @return bool
998 */
999 private static function is_placeable( $settings, $key ) {
1000 if ( isset( $settings[ $key ] ) && self::js_truthy( $settings[ $key ] ) ) {
1001 return true;
1002 }
1003
1004 foreach ( $settings as $value ) {
1005 if ( ! is_array( $value ) ) {
1006 continue;
1007 }
1008
1009 if ( self::is_list( $value ) ) {
1010 foreach ( $value as $row ) {
1011 if ( is_array( $row ) && isset( $row[ $key ] ) && self::js_truthy( $row[ $key ] ) ) {
1012 return true;
1013 }
1014 }
1015 } elseif ( isset( $value[ $key ] ) && self::js_truthy( $value[ $key ] ) ) {
1016 return true;
1017 }
1018 }
1019
1020 return false;
1021 }
1022
1023 /**
1024 * Write text into the first text widget found inside one item container.
1025 *
1026 * Written as plain text, exactly as apply_elementor_value() writes an `editor` value, so
1027 * a routed answer and a directly merged one render the same way.
1028 *
1029 * @param array $node Item container (by reference).
1030 * @param string $value Text.
1031 * @return bool Whether a text widget was found.
1032 */
1033 private static function write_first_text_editor( &$node, $value ) {
1034 if ( isset( $node['widgetType'] ) && 'text-editor' === $node['widgetType'] && isset( $node['settings'] ) && is_array( $node['settings'] ) ) {
1035 $node['settings']['editor'] = $value;
1036
1037 return true;
1038 }
1039
1040 if ( empty( $node['elements'] ) || ! is_array( $node['elements'] ) ) {
1041 return false;
1042 }
1043
1044 foreach ( $node['elements'] as &$child ) {
1045 if ( is_array( $child ) && self::write_first_text_editor( $child, $value ) ) {
1046 unset( $child );
1047
1048 return true;
1049 }
1050 }
1051
1052 unset( $child );
1053
1054 return false;
1055 }
1056
1057 /**
1058 * Repeater settings the AI answered with fewer values than the repeater has rows.
1059 *
1060 * apply_elementor_value() writes a repeater row by row — row N takes value N — and a row
1061 * with no value is left alone, so it keeps the TEMPLATE's copy. When the model under-
1062 * delivers, which it does (asked for "a list of 5 process steps", it returned one step
1063 * with all five crammed into its description), the untouched rows go live still talking
1064 * about the demo brand: "Zion was founded with a bold vision to transform the SaaS
1065 * landscape…" on a freight company's About page, four times over.
1066 *
1067 * The row addressing here is exactly apply_elementor_value()'s — the same `is_list()`
1068 * test on each setting and the same `js_truthy()` test on each row — so "rows that will
1069 * be skipped" means what the merge will actually skip, not an approximation of it.
1070 * Direct (non-repeater) settings only ever take the first value and are never short.
1071 *
1072 * @since 2.7.2
1073 *
1074 * @param array $tree Elementor element tree.
1075 * @param array $payload Canonical payload.
1076 * @return array id => { need: int, keys: { setting_key: { need, have } } }
1077 */
1078 public static function repeater_shortfalls( $tree, $payload ) {
1079 $index = self::index_by_id( $payload );
1080 $out = array();
1081
1082 if ( empty( $index ) || ! is_array( $tree ) ) {
1083 return $out;
1084 }
1085
1086 self::walk_shortfalls( $tree, $index, $out );
1087
1088 return $out;
1089 }
1090
1091 /**
1092 * @param array $elements Element list.
1093 * @param array $index id => data pairs.
1094 * @param array $out Result (by reference).
1095 * @return void
1096 */
1097 private static function walk_shortfalls( $elements, $index, &$out ) {
1098 foreach ( (array) $elements as $element ) {
1099 if ( ! is_array( $element ) ) {
1100 continue;
1101 }
1102
1103 $id = isset( $element['id'] ) ? (string) $element['id'] : '';
1104
1105 if ( isset( $element['elType'] ) && 'widget' === $element['elType'] && '' !== $id && isset( $index[ $id ] )
1106 && isset( $element['settings'] ) && is_array( $element['settings'] ) ) {
1107 $settings = $element['settings'];
1108
1109 foreach ( (array) $index[ $id ] as $pair ) {
1110 foreach ( (array) $pair as $key => $values ) {
1111 /* A direct hit takes only the first value — never short. */
1112 if ( isset( $settings[ $key ] ) && self::js_truthy( $settings[ $key ] ) ) {
1113 continue;
1114 }
1115
1116 $have = is_array( $values ) ? count( $values ) : 0;
1117
1118 foreach ( $settings as $s_val ) {
1119 if ( ! is_array( $s_val ) || ! self::is_list( $s_val ) ) {
1120 continue;
1121 }
1122
1123 /* Rows are addressed by POSITION, so what matters is the last row
1124 * that will ask for a value, not how many do. */
1125 $last = -1;
1126
1127 foreach ( $s_val as $row_index => $row ) {
1128 if ( is_array( $row ) && isset( $row[ $key ] ) && self::js_truthy( $row[ $key ] ) ) {
1129 $last = (int) $row_index;
1130 }
1131 }
1132
1133 $need = $last + 1;
1134
1135 if ( $need > $have ) {
1136 $out[ $id ]['keys'][ $key ] = array(
1137 'need' => $need,
1138 'have' => $have,
1139 );
1140 $out[ $id ]['need'] = max( isset( $out[ $id ]['need'] ) ? $out[ $id ]['need'] : 0, $need );
1141 }
1142 }
1143
1144 /* A nested widget's per-item text that has no field on the widget itself
1145 * — the answers route_to_nested_items() places into each item's own
1146 * container. That routing only runs with a value for EVERY item, so a
1147 * shorter list is exactly as short as an under-filled repeater: it left
1148 * the About Us FAQ showing three-word headings ("Our Services Overview")
1149 * under each question, because the model returned one answer for three
1150 * items. Counting it here lets the follow-up request the full list. */
1151 $type = isset( $element['widgetType'] ) ? (string) $element['widgetType'] : '';
1152
1153 if ( in_array( $type, self::$nested_item_widgets, true ) && ! self::is_placeable( $settings, $key )
1154 && ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
1155 $need = count( $element['elements'] );
1156
1157 if ( $need > $have ) {
1158 $out[ $id ]['keys'][ $key ] = array(
1159 'need' => $need,
1160 'have' => $have,
1161 );
1162 $out[ $id ]['need'] = max( isset( $out[ $id ]['need'] ) ? $out[ $id ]['need'] : 0, $need );
1163 }
1164 }
1165 }
1166 }
1167 }
1168
1169 if ( ! empty( $element['elements'] ) ) {
1170 self::walk_shortfalls( $element['elements'], $index, $out );
1171 }
1172 }
1173 }
1174
1175 /**
1176 * Replace one setting's values for one element in a canonical payload.
1177 *
1178 * @since 2.7.2
1179 *
1180 * @param array $payload Canonical payload.
1181 * @param string $id Element id.
1182 * @param string $key Setting key.
1183 * @param array $values New values.
1184 * @return array Updated payload.
1185 */
1186 public static function replace_payload_values( $payload, $id, $key, $values ) {
1187 if ( empty( $payload['elements'] ) || ! is_array( $payload['elements'] ) ) {
1188 return $payload;
1189 }
1190
1191 foreach ( $payload['elements'] as &$entry ) {
1192 if ( ! isset( $entry['id'] ) || (string) $entry['id'] !== (string) $id || empty( $entry['data'] ) ) {
1193 continue;
1194 }
1195
1196 foreach ( $entry['data'] as &$pair ) {
1197 if ( is_array( $pair ) && array_key_exists( $key, $pair ) ) {
1198 $pair[ $key ] = array_values( $values );
1199 }
1200 }
1201
1202 unset( $pair );
1203 }
1204
1205 unset( $entry );
1206
1207 return $payload;
1208 }
1209
1210 /**
1211 * Write one key's AI values into one widget's settings.
1212 *
1213 * @param array $settings Widget settings (by reference).
1214 * @param string $key Setting key.
1215 * @param array $values AI values (always a list).
1216 * @return void
1217 */
1218 private static function apply_elementor_value( &$settings, $key, $values ) {
1219 $first = array_key_exists( 0, $values ) ? $values[0] : null;
1220 $has_first = array_key_exists( 0, $values );
1221
1222 /** direct hit on the widget's own settings */
1223 if ( isset( $settings[ $key ] ) && self::js_truthy( $settings[ $key ] ) ) {
1224
1225 if ( in_array( $key, array( 'image', 'svg_logo' ), true ) && is_array( $settings[ $key ] ) && array_key_exists( 'url', $settings[ $key ] ) ) {
1226 $settings[ $key ]['url'] = $first;
1227 return;
1228 }
1229
1230 if ( in_array( $key, array( 'link', 'sc_link' ), true ) && is_array( $settings[ $key ] ) && ! empty( $settings[ $key ]['url'] ) ) {
1231 $settings[ $key ]['url'] = $first;
1232 return;
1233 }
1234
1235 if ( 'icon_fontawesome_5' === $key ) {
1236 /* Only replace when the AI actually returned an icon; otherwise the
1237 * template's own icon is wiped and an empty box renders. The library is
1238 * derived from the prefix rather than forced to fa-solid, which would
1239 * blank brand/regular/light icons. */
1240 $icon = ( null !== $first && '' !== $first ) ? self::font_awesome_5_icon( $first ) : null;
1241
1242 if ( null !== $icon ) {
1243 if ( ! is_array( $settings[ $key ] ) ) {
1244 $settings[ $key ] = array();
1245 }
1246
1247 $settings[ $key ]['value'] = $icon['value'];
1248 $settings[ $key ]['library'] = $icon['library'];
1249 }
1250
1251 return;
1252 }
1253
1254 if ( $has_first ) {
1255 $settings[ $key ] = $first;
1256 }
1257
1258 return;
1259 }
1260
1261 /** not on the widget directly — look inside repeaters and nested objects */
1262 foreach ( $settings as $s_key => &$s_val ) {
1263 if ( ! is_array( $s_val ) ) {
1264 continue;
1265 }
1266
1267 if ( self::is_list( $s_val ) ) {
1268 foreach ( $s_val as $row_index => &$row ) {
1269 if ( ! is_array( $row ) ) {
1270 continue;
1271 }
1272
1273 if ( ! isset( $row[ $key ] ) || ! self::js_truthy( $row[ $key ] ) ) {
1274 continue;
1275 }
1276
1277 /* Social rows are addressed by the network the ROW says it is, not by
1278 * position. The values list follows the template's declared
1279 * `wdkitai_social_icon_order`, and a kit whose footer carries networks
1280 * that order does not name - Facebook, Instagram, WhatsApp, X against an
1281 * order of three - shifted every row after the missing one, so the
1282 * WhatsApp icon linked to x.com (ClickUp 14ynqxyykay). Position is still
1283 * the fallback for any row that does not say what it is. */
1284 $network = ( in_array( $key, array( 'social_url', 'social_icon', 'link', 'sc_link' ), true ) && ! empty( self::$social_by_network ) )
1285 ? self::row_network( $row )
1286 : '';
1287
1288 if ( '' !== $network ) {
1289 /* A network the visitor did not give is blanked rather than left on
1290 * the template's demo account. */
1291 $value = isset( self::$social_by_network[ $network ] ) ? self::$social_by_network[ $network ] : '';
1292 } elseif ( array_key_exists( $row_index, $values ) ) {
1293 $value = $values[ $row_index ];
1294 } else {
1295 continue;
1296 }
1297
1298 if ( in_array( $key, array( 'social_url', 'link', 'sc_link' ), true ) && is_array( $row[ $key ] ) && array_key_exists( 'url', $row[ $key ] ) ) {
1299 $row[ $key ]['url'] = $value;
1300 } elseif ( 'social_icon' === $key && isset( $row['link'] ) && is_array( $row['link'] ) ) {
1301 $row['link']['url'] = $value;
1302 } else {
1303 $row[ $key ] = $value;
1304 }
1305 }
1306
1307 unset( $row );
1308 continue;
1309 }
1310
1311 if ( isset( $s_val[ $key ] ) && self::js_truthy( $s_val[ $key ] ) ) {
1312 $s_val[ $key ] = $first;
1313 }
1314 }
1315
1316 unset( $s_val );
1317 }
1318
1319 /*
1320 |--------------------------------------------------------------------------
1321 | 4. Gutenberg merge
1322 |--------------------------------------------------------------------------
1323 */
1324
1325 /**
1326 * Merge AI copy into a parsed Nexter block tree.
1327 *
1328 * Port of replace_guten_new_content(). Nodes are `{ type, object, inner_con[] }` and
1329 * identity is `object.block_id`. Any node that receives a replacement is flagged
1330 * `blockRender = true`, which is what tells the block to re-render its saved HTML.
1331 *
1332 * @param array $nodes Parsed block nodes (by reference).
1333 * @param array $payload Canonical payload.
1334 * @return int Number of node matches applied.
1335 */
1336 public static function merge_gutenberg( &$nodes, $payload ) {
1337 $index = self::index_by_id( $payload );
1338
1339 if ( empty( $index ) || ! is_array( $nodes ) ) {
1340 return 0;
1341 }
1342
1343 $applied = 0;
1344
1345 self::walk_gutenberg( $nodes, $index, $applied );
1346
1347 return $applied;
1348 }
1349
1350 /**
1351 * @param array $nodes Node list (by reference).
1352 * @param array $index id => data pairs.
1353 * @param int $applied Counter (by reference).
1354 * @return void
1355 */
1356 private static function walk_gutenberg( &$nodes, $index, &$applied ) {
1357 if ( ! is_array( $nodes ) ) {
1358 return;
1359 }
1360
1361 foreach ( $nodes as &$node ) {
1362 if ( ! is_array( $node ) ) {
1363 continue;
1364 }
1365
1366 $type = isset( $node['type'] ) ? (string) $node['type'] : '';
1367
1368 if ( 'tp-container' !== $type && isset( $node['object'] ) && is_array( $node['object'] ) ) {
1369
1370 /* An editor-mode accordion holds its panels as inner blocks that also carry
1371 * their own ids, so it is descended into as well as being matched itself. */
1372 if ( 'tp-accordion' === $type && isset( $node['object']['accorType'] ) && 'editor' === $node['object']['accorType'] && isset( $node['inner_con'] ) && is_array( $node['inner_con'] ) ) {
1373 self::walk_gutenberg( $node['inner_con'], $index, $applied );
1374 }
1375
1376 $block_id = isset( $node['object']['block_id'] ) ? (string) $node['object']['block_id'] : '';
1377
1378 if ( '' !== $block_id && isset( $index[ $block_id ] ) ) {
1379 foreach ( $index[ $block_id ] as $pair ) {
1380 foreach ( $pair as $key => $values ) {
1381 self::apply_gutenberg_value( $node['object'], $key, $values );
1382 }
1383 }
1384
1385 $node['object']['blockRender'] = true;
1386
1387 ++$applied;
1388 }
1389 }
1390
1391 if ( isset( $node['inner_con'] ) && is_array( $node['inner_con'] ) ) {
1392 self::walk_gutenberg( $node['inner_con'], $index, $applied );
1393 }
1394 }
1395
1396 unset( $node );
1397 }
1398
1399 /**
1400 * Merge AI copy into WordPress's own parsed block structure.
1401 *
1402 * This is the form the PHP page importer uses. `parse_blocks()` returns
1403 * `{blockName, attrs, innerBlocks, innerHTML, innerContent}` — the attributes live in
1404 * `attrs`, which is the same object the browser calls `object`. Working on this
1405 * structure rather than porting the JS regex parser means:
1406 *
1407 * - there is only one Gutenberg representation in PHP, WordPress's
1408 * - `serialize_blocks()` writes the markup back, so no string surgery on block
1409 * comments and no JSON-escaping mismatch between PHP and JS
1410 * - the existing `import_page_section_content()` pipeline (media import → Nexter
1411 * processor → serialize) applies unchanged
1412 *
1413 * @param array $blocks Output of parse_blocks() (by reference).
1414 * @param array $payload Canonical payload.
1415 * @return int Number of block matches applied.
1416 */
1417 public static function merge_gutenberg_blocks( &$blocks, $payload ) {
1418 $index = self::index_by_id( $payload );
1419
1420 if ( empty( $index ) || ! is_array( $blocks ) ) {
1421 return 0;
1422 }
1423
1424 $applied = 0;
1425
1426 self::walk_wp_blocks( $blocks, $index, $applied );
1427
1428 return $applied;
1429 }
1430
1431 /**
1432 * @param array $blocks Block list (by reference).
1433 * @param array $index id => data pairs.
1434 * @param int $applied Counter (by reference).
1435 * @return void
1436 */
1437 private static function walk_wp_blocks( &$blocks, $index, &$applied ) {
1438 if ( ! is_array( $blocks ) ) {
1439 return;
1440 }
1441
1442 foreach ( $blocks as &$block ) {
1443 if ( ! is_array( $block ) ) {
1444 continue;
1445 }
1446
1447 if ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
1448 $block_id = isset( $block['attrs']['block_id'] ) ? (string) $block['attrs']['block_id'] : '';
1449
1450 if ( '' !== $block_id && isset( $index[ $block_id ] ) ) {
1451 foreach ( $index[ $block_id ] as $pair ) {
1452 foreach ( $pair as $key => $values ) {
1453 self::apply_gutenberg_value( $block['attrs'], $key, $values );
1454 }
1455 }
1456
1457 /* Tells the block to re-render from its attributes rather than trusting
1458 * the saved HTML, which now describes the pre-AI copy. */
1459 $block['attrs']['blockRender'] = true;
1460
1461 ++$applied;
1462 }
1463 }
1464
1465 if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1466 self::walk_wp_blocks( $block['innerBlocks'], $index, $applied );
1467 }
1468 }
1469
1470 unset( $block );
1471 }
1472
1473 /**
1474 * Write one key's AI values into one block's attribute object.
1475 *
1476 * @param array $object Block attributes (by reference).
1477 * @param string $key Attribute key.
1478 * @param array $values AI values.
1479 * @return void
1480 */
1481 private static function apply_gutenberg_value( &$object, $key, $values ) {
1482 $first = array_key_exists( 0, $values ) ? $values[0] : null;
1483 $has_first = array_key_exists( 0, $values );
1484
1485 if ( isset( $object[ $key ] ) && self::js_truthy( $object[ $key ] ) ) {
1486
1487 if ( in_array( $key, array( 'image', 'tImg', 'TImage' ), true ) && is_array( $object[ $key ] ) && array_key_exists( 'url', $object[ $key ] ) ) {
1488 $object[ $key ]['url'] = $first;
1489 return;
1490 }
1491
1492 if ( 'accordianList' === $key ) {
1493 if ( is_array( $object[ $key ] ) && isset( $object[ $key ][0] ) && is_array( $object[ $key ][0] ) ) {
1494 $object[ $key ][0]['title'] = $first;
1495 }
1496
1497 $object['title'] = $first;
1498 return;
1499 }
1500
1501 if ( in_array( $key, self::$gutenberg_link_keys, true ) && is_array( $object[ $key ] ) && ! empty( $object[ $key ]['url'] ) ) {
1502 /** never blank an existing link */
1503 if ( self::is_usable_link( $first ) ) {
1504 $object[ $key ]['url'] = $first;
1505 }
1506
1507 return;
1508 }
1509
1510 if ( $has_first ) {
1511 $object[ $key ] = $first;
1512 }
1513
1514 return;
1515 }
1516
1517 /* Look inside nested attribute objects and repeaters. `check_count` resets per
1518 * attribute, matching the JS: when a given attribute yields no match, an
1519 * allow-listed key may be written straight onto the block object instead. */
1520 foreach ( $object as $s_key => &$s_val ) {
1521 $check_count = 0;
1522
1523 if ( is_array( $s_val ) ) {
1524 if ( self::is_list( $s_val ) ) {
1525 foreach ( $s_val as $row_index => &$row ) {
1526 if ( ! is_array( $row ) ) {
1527 continue;
1528 }
1529
1530 if ( ! isset( $row[ $key ] ) || ! self::js_truthy( $row[ $key ] ) ) {
1531 continue;
1532 }
1533
1534 if ( ! array_key_exists( $row_index, $values ) ) {
1535 continue;
1536 }
1537
1538 ++$check_count;
1539
1540 $value = $values[ $row_index ];
1541
1542 if ( in_array( $key, array( 'linkUrl', 'link' ), true ) && is_array( $row[ $key ] ) && array_key_exists( 'url', $row[ $key ] ) ) {
1543 /* An empty value used to overwrite the template's own URL, and
1544 * blocks that gate their whole output on having one — social
1545 * icons among them — then rendered an empty wrapper. */
1546 if ( self::is_usable_link( $value ) ) {
1547 $row[ $key ]['url'] = $value;
1548 }
1549 } elseif ( 'descurl' === $key && is_array( $row[ $key ] ) && array_key_exists( 'url', $row[ $key ] ) ) {
1550 $row[ $key ]['url'] = $value;
1551 } else {
1552 $row[ $key ] = $value;
1553 }
1554 }
1555
1556 unset( $row );
1557 } elseif ( isset( $s_val[ $key ] ) && self::js_truthy( $s_val[ $key ] ) && $has_first ) {
1558 $s_val[ $key ] = $first;
1559 }
1560 }
1561
1562 if ( 0 === $check_count && in_array( $key, self::$gutenberg_allowed_keys, true ) ) {
1563 $object[ $key ] = $first;
1564 }
1565 }
1566
1567 unset( $s_val );
1568 }
1569
1570 /*
1571 |--------------------------------------------------------------------------
1572 | 4b. Document contract — pages + products + posts in one payload
1573 |--------------------------------------------------------------------------
1574 |
1575 | normalize_payload() above is the *page* contract: one template's worth of element
1576 | replacements. It is what the browser already produces and consumes, and it does not
1577 | change.
1578 |
1579 | A remote caller, though, has to hand over a whole site's generated copy in one go.
1580 | That is this contract:
1581 |
1582 | [ 'version' => 1,
1583 | 'builder' => 'elementor'|'gutenberg',
1584 | 'pages' => [ '<template_id>' => <page payload>, ... ],
1585 | 'products' => [ [ 'source_id', 'title', 'description', 'price', 'category' ] ],
1586 | 'posts' => [ [ 'source_id', 'title', 'category', 'tags' ] ],
1587 | 'taxonomy' => [ 'categories' => [ names ], 'tags' => [ names ] ] ]
1588 |
1589 | Backward compatibility is exact, not approximate: a bare element list — anything
1590 | normalize_payload() accepts today — still normalises, and lands on the document's
1591 | own `elements` key, so `normalize_document( $x )['elements']` equals
1592 | `normalize_payload( $x )['elements']` for every legacy input. There is one
1593 | implementation of the merge and one shape feeding it.
1594 |
1595 | ── Why the product and post fields are a closed list ──────────────────
1596 |
1597 | These two contracts do not merge into an existing tree the way pages do; they create
1598 | WordPress objects. So the whitelist is the security boundary, and it is deliberately
1599 | narrower than what WordPress would accept. Everything a generated payload could use
1600 | to reach past its own content — post_author, post_status, ID, post_type, meta_input,
1601 | ping_status, guid, comment_status — is not on it, and unknown keys are dropped rather
1602 | than passed through. The fields that ARE here are exactly the ones the existing
1603 | browser importer fills from `wkit_generate_product_data` /
1604 | `wkit_generate_post_data`; see Wdkit_Import_Products and Wdkit_Import_Posts for the
1605 | trace.
1606 */
1607
1608 /**
1609 * Product fields a generated payload may set. Nothing else survives normalisation.
1610 *
1611 * @var string[]
1612 */
1613 private static $product_fields = array( 'source_id', 'title', 'description', 'price', 'category' );
1614
1615 /**
1616 * Post fields a generated payload may set. Note the absences: no author, no status, no
1617 * post id, no content — post content comes from the template through the page merge,
1618 * exactly as it does in the browser.
1619 *
1620 * @var string[]
1621 */
1622 private static $post_fields = array( 'source_id', 'title', 'category', 'tags' );
1623
1624 /** Hard caps, so a payload cannot be used to bloat the options table or a post row. */
1625 const MAX_TITLE = 200;
1626 const MAX_DESCRIPTION = 2000;
1627 const MAX_TERMS = 30;
1628 const MAX_RECORDS = 200;
1629
1630 /**
1631 * Build the AI request from a Gutenberg block tree.
1632 *
1633 * extract_ai_object() above understands the Elementor shape only — `settings`,
1634 * `elType`, `elements`. Gutenberg templates arrive from parse_blocks() as `attrs`,
1635 * `blockName`, `innerBlocks`, and their section marker is a different pair of keys
1636 * entirely, so running the Elementor walker over them silently found nothing and the
1637 * import fell back to template copy. Caught by running an ai_import end to end.
1638 *
1639 * Port of get_gutenberg_ai_object() in import_loader.js, including its exact tests:
1640 *
1641 * section type is `tp-container` AND attrs.enable_ai_meta_info === true
1642 * AND attrs.wdkitai_sectionType is set
1643 * custom wdkitai_sectionType === 'wdkit_custom' => the label is wdkitai_conTitle
1644 * passthrough a tp-container that is NOT a section still recurses, keeping the
1645 * section it inherited
1646 * leaf any block with attrs, inside a section: collect every `wdkitai_*` key,
1647 * id = attrs.block_id
1648 * excluded the meta keys that describe rather than request, plus any `*_replace`
1649 * key, whose VALUE names the attribute the answer is written to
1650 *
1651 * @param array $blocks parse_blocks() output.
1652 * @param array $section_labels section => description.
1653 * @return array Request shape: section => {information, elements:[{id, wdkitai_*}]}.
1654 */
1655 public static function extract_ai_object_gutenberg( $blocks, $section_labels = array() ) {
1656 return self::extract_ai_pair_gutenberg( $blocks, $section_labels )['request'];
1657 }
1658
1659 /**
1660 * Both halves of the extraction.
1661 *
1662 * `request` is what goes to the AI: filtered, no `*_replace`, no descriptive meta.
1663 * `detail` is what stays here: the SAME elements unfiltered, so the answer can be
1664 * translated afterwards — the AI replies keyed by instruction
1665 * (`wdkitai_paragraphTitle`) while the content has to be written to the
1666 * attribute that instruction's `_replace` names (`exTitle`). Without the
1667 * detail map there is nothing to resolve that against, which is exactly how
1668 * the first ai_import through the wizard imported template copy instead.
1669 *
1670 * @param array $blocks parse_blocks() output.
1671 * @param array $section_labels section => description.
1672 * @return array{request:array,detail:array}
1673 */
1674 public static function extract_ai_pair_gutenberg( $blocks, $section_labels = array() ) {
1675 $section_map = array();
1676 $detail_section = array();
1677
1678 self::traverse_blocks_for_instructions( $blocks, null, $section_labels, $section_map, $detail_section );
1679
1680 return array(
1681 'request' => $section_map,
1682 'detail' => $detail_section,
1683 );
1684 }
1685
1686 /**
1687 * Translate an AI answer into the canonical payload.
1688 *
1689 * Port of the conversion loop in replace_elementor_txt(): the answer arrives as
1690 *
1691 * [ { "<section>": { information, elements: [ {id, wdkitai_X: [values]} ] } } ]
1692 *
1693 * and every `wdkitai_X` is rewritten to the attribute named by `wdkitai_X_replace` on
1694 * the matching template element. An instruction with no `_replace` target is dropped,
1695 * which is what the JS does too — `if ( o_txt?.[find_key] )`.
1696 *
1697 * @param mixed $answer Decoded answer, or a JSON string.
1698 * @param array $detail The `detail` map from extract_ai_pair_gutenberg().
1699 * @param string $builder Builder.
1700 * @return array Canonical payload.
1701 */
1702 public static function answer_to_payload( $answer, $detail, $builder = 'gutenberg' ) {
1703 if ( is_string( $answer ) ) {
1704 $answer = self::decode_answer( $answer );
1705 }
1706
1707 $payload = array(
1708 'version' => self::CONTRACT_VERSION,
1709 'builder' => ( 'gutenberg' === $builder ) ? 'gutenberg' : 'elementor',
1710 'elements' => array(),
1711 );
1712
1713 if ( ! is_array( $answer ) ) {
1714 return $payload;
1715 }
1716
1717 /* id => unfiltered instruction object, flattened across sections. */
1718 $by_id = array();
1719
1720 foreach ( (array) $detail as $section ) {
1721 foreach ( (array) ( $section['elements'] ?? array() ) as $element ) {
1722 if ( is_array( $element ) && ! empty( $element['id'] ) ) {
1723 $by_id[ (string) $element['id'] ] = $element;
1724 }
1725 }
1726 }
1727
1728 /* The answer is a list of section-keyed objects. */
1729 foreach ( $answer as $entry ) {
1730 if ( ! is_array( $entry ) ) {
1731 continue;
1732 }
1733
1734 foreach ( $entry as $section ) {
1735 if ( ! is_array( $section ) || empty( $section['elements'] ) || ! is_array( $section['elements'] ) ) {
1736 continue;
1737 }
1738
1739 foreach ( $section['elements'] as $element ) {
1740 if ( ! is_array( $element ) || empty( $element['id'] ) ) {
1741 continue;
1742 }
1743
1744 $id = (string) $element['id'];
1745 $origin = isset( $by_id[ $id ] ) ? $by_id[ $id ] : array();
1746 $pairs = array();
1747
1748 foreach ( $element as $key => $values ) {
1749 if ( 'id' === $key ) {
1750 continue;
1751 }
1752
1753 $values = is_array( $values ) ? array_values( $values ) : array( $values );
1754
1755 if ( empty( $values ) ) {
1756 continue;
1757 }
1758
1759 $target_key = $key . '_replace';
1760
1761 /* No declared target means the template has nowhere to put this. */
1762 if ( empty( $origin[ $target_key ] ) || ! is_string( $origin[ $target_key ] ) ) {
1763 continue;
1764 }
1765
1766 $pairs[] = array( $origin[ $target_key ] => $values );
1767 }
1768
1769 if ( ! empty( $pairs ) ) {
1770 $payload['elements'][] = array(
1771 'id' => $id,
1772 'data' => $pairs,
1773 );
1774 }
1775 }
1776 }
1777 }
1778
1779 return $payload;
1780 }
1781
1782 /**
1783 * Turn the browser's batched-AI answer for one template into the canonical payload.
1784 *
1785 * ── Why this exists ────────────────────────────────────────────────────────
1786 *
1787 * The batched flow (generate_ai_content_batch) answers per template in the shape
1788 * replace_elementor_txt() consumes, NOT the shape normalize_document() accepts:
1789 *
1790 * [ { "<page_type>": { "<section>": { elements: [ { id, wdkitai_X: [values] } ] } } } ]
1791 *
1792 * - it is nested by page-type and then section (a JSON string that has been
1793 * `JSON.parse`d, so a real array here),
1794 * - each `wdkitai_X` still names the INSTRUCTION, not the Elementor/block attribute it
1795 * writes into. The target attribute lives on the template as `wdkitai_X_replace`.
1796 *
1797 * normalize_payload() only knows the already-resolved `[{id,data:[{attr:[vals]}]}]`
1798 * form, so left alone it produces an empty payload and the runner falls back to a
1799 * per-template cloud call for every page. This does the resolution the browser does:
1800 * read the `_replace` targets off the template's own instruction map and rewrite the
1801 * answer onto them.
1802 *
1803 * Faithful to replace_elementor_txt()/replace_guten_new_content(): the loop is driven
1804 * by the template's instructions, the answer only supplies values, an instruction with
1805 * no `_replace` target is dropped, and the site-info-driven instructions
1806 * (social-icon order, admin-detail order, site logo) are filled from `$site_info`
1807 * exactly as the JS fills them from `site_obj.site_info`.
1808 *
1809 * @since 2.7.2
1810 *
1811 * @param mixed $answer Decoded answer for ONE template (or its JSON string).
1812 * @param array $tree The template's element tree (Elementor list) or parsed blocks.
1813 * @param string $file_type 'elementor' | 'wp_block'.
1814 * @param array $site_info Visitor's site info, for the logo/social/contact instructions.
1815 * @return array Canonical payload: {version, builder, elements:[{id,data:[{attr:[vals]}]}]}.
1816 */
1817 public static function browser_answer_to_payload( $answer, $tree, $file_type = 'elementor', $site_info = array() ) {
1818 if ( is_string( $answer ) ) {
1819 $answer = self::decode_answer( $answer );
1820 }
1821
1822 $is_block = ( 'wp_block' === $file_type || 'gutenberg' === $file_type );
1823 $builder = $is_block ? 'gutenberg' : 'elementor';
1824
1825 $payload = array(
1826 'version' => self::CONTRACT_VERSION,
1827 'builder' => $builder,
1828 'elements' => array(),
1829 );
1830
1831 if ( ! is_array( $answer ) || ! is_array( $tree ) || empty( $tree ) ) {
1832 return $payload;
1833 }
1834
1835 /* id => { instruction_key => [values] }, gathered from wherever the answer nests
1836 * its element lists (page-type wrapper, section wrapper, or neither). */
1837 $answer_by_id = self::flatten_ai_answer( $answer );
1838
1839 if ( empty( $answer_by_id ) ) {
1840 return $payload;
1841 }
1842
1843 /* id => the template's own instruction element, which carries every `*_replace`
1844 * target. This is the same map replace_elementor_txt() calls `old_text`. */
1845 $detail = $is_block
1846 ? self::extract_ai_pair_gutenberg( $tree )['detail']
1847 : self::extract_ai_object( $tree )['detailsection'];
1848
1849 $detail_by_id = array();
1850
1851 foreach ( (array) $detail as $section ) {
1852 if ( ! is_array( $section ) || empty( $section['elements'] ) || ! is_array( $section['elements'] ) ) {
1853 continue;
1854 }
1855
1856 foreach ( $section['elements'] as $element ) {
1857 if ( is_array( $element ) && ! empty( $element['id'] ) ) {
1858 $detail_by_id[ (string) $element['id'] ] = $element;
1859 }
1860 }
1861 }
1862
1863 if ( empty( $detail_by_id ) ) {
1864 return $payload;
1865 }
1866
1867 $site_info = is_array( $site_info ) ? $site_info : array();
1868
1869 foreach ( $detail_by_id as $id => $origin ) {
1870 $from_answer = isset( $answer_by_id[ $id ] ) && is_array( $answer_by_id[ $id ] ) ? $answer_by_id[ $id ] : array();
1871 $pairs = array();
1872
1873 foreach ( $origin as $key => $default ) {
1874 if ( 'id' === $key || 'wdkitai_image_type' === $key ) {
1875 continue;
1876 }
1877
1878 if ( '_replace' === substr( (string) $key, -8 ) ) {
1879 continue;
1880 }
1881
1882 /* The answer's value wins; the template's own value is the fallback, and a
1883 * plain string fallback is never emitted (see the Array check below), so an
1884 * un-answered instruction leaves the template copy in place. */
1885 $value = array_key_exists( $key, $from_answer ) ? $from_answer[ $key ] : $default;
1886
1887 $target = self::replace_target( $origin, $key );
1888
1889 if ( 'wdkitai_social_icon_order' === $key ) {
1890 $value = self::social_icon_values( $value, $site_info );
1891 } elseif ( 'wdkitai_admin_detail_order' === $key && 'info' === ( $origin['wdkitai_info_type'] ?? '' ) ) {
1892 list( $text_vals, $link_vals ) = self::admin_detail_values( $value, $site_info );
1893
1894 if ( '' !== $target && ! empty( $text_vals ) ) {
1895 $pairs[] = array( $target => $text_vals );
1896
1897 $link_target = self::replace_target( $origin, 'wdkitai_admin_detail_link' );
1898
1899 if ( '' !== $link_target ) {
1900 $pairs[] = array( $link_target => $link_vals );
1901 }
1902 }
1903
1904 continue;
1905 } elseif ( 'wdkitai_image_description_type' === $key && 'site_logo' === $default && ! empty( $site_info['logo'] ) ) {
1906 $logo = self::site_logo_value( $origin, $site_info );
1907
1908 if ( '' !== $logo ) {
1909 /* Write to the BLOCK's field, not to the instruction key.
1910 *
1911 * This used to emit `wdkitai_image_type` literally, which is the name
1912 * of the instruction — the thing that says what to replace — not of
1913 * anything the block renders. The logo landed in the block's attribute
1914 * bag next to its own instructions and nothing ever read it, so the
1915 * header kept the kit's logo on every AI import. The Zion site-logo
1916 * block shows the shape exactly:
1917 *
1918 * logoType : "svg"
1919 * wdkitai_image_type : <where the logo was being put>
1920 * wdkitai_image_type_replace : "svgStore" <- the real field
1921 * wdkitai_image_description_type : "site_logo"
1922 *
1923 * `replace_target()` is how every other branch in this loop resolves
1924 * that name; this one simply never called it. */
1925 $logo_target = self::replace_target( $origin, 'wdkitai_image_type' );
1926
1927 if ( '' !== $logo_target ) {
1928 $pairs[] = array( $logo_target => array( $logo ) );
1929 }
1930 }
1931
1932 continue;
1933 }
1934
1935 if ( '' === $target || ! is_array( $value ) ) {
1936 continue;
1937 }
1938
1939 $value = array_values(
1940 array_filter(
1941 $value,
1942 static function ( $v ) {
1943 return null !== $v && '' !== $v && ! is_array( $v );
1944 }
1945 )
1946 );
1947
1948 if ( ! empty( $value ) ) {
1949 $pairs[] = array( $target => $value );
1950 }
1951 }
1952
1953 if ( ! empty( $pairs ) ) {
1954 $payload['elements'][] = array(
1955 'id' => (string) $id,
1956 'data' => $pairs,
1957 );
1958 }
1959 }
1960
1961 return $payload;
1962 }
1963
1964 /**
1965 * Collect every `{id, wdkitai_X: [...]}` element out of an AI answer, whatever it is
1966 * nested inside. The batch answer wraps its element lists in a page-type object and
1967 * then a section object; older/other shapes drop a level. Recursing to the first
1968 * `elements` list handles all of them.
1969 *
1970 * @param mixed $node Answer, or a fragment of one.
1971 * @return array<string,array> id => { key => value }.
1972 */
1973 private static function flatten_ai_answer( $node ) {
1974 $out = array();
1975
1976 self::flatten_ai_answer_into( $node, $out );
1977
1978 return $out;
1979 }
1980
1981 /**
1982 * @param mixed $node Node.
1983 * @param array $out Accumulator (by reference).
1984 * @return void
1985 */
1986 private static function flatten_ai_answer_into( $node, &$out ) {
1987 if ( ! is_array( $node ) ) {
1988 return;
1989 }
1990
1991 if ( isset( $node['elements'] ) && is_array( $node['elements'] ) ) {
1992 foreach ( $node['elements'] as $element ) {
1993 if ( ! is_array( $element ) || empty( $element['id'] ) ) {
1994 continue;
1995 }
1996
1997 $id = (string) $element['id'];
1998
1999 if ( ! isset( $out[ $id ] ) ) {
2000 $out[ $id ] = array();
2001 }
2002
2003 foreach ( $element as $key => $value ) {
2004 if ( 'id' === $key ) {
2005 continue;
2006 }
2007
2008 $out[ $id ][ trim( (string) $key ) ] = $value;
2009 }
2010 }
2011
2012 return;
2013 }
2014
2015 foreach ( $node as $child ) {
2016 self::flatten_ai_answer_into( $child, $out );
2017 }
2018 }
2019
2020 /**
2021 * The Elementor/block attribute an instruction writes into, read off the template's
2022 * own `<key>_replace` (which extract_ai_object() has occasionally seen with a trailing
2023 * space, so both are tried).
2024 *
2025 * @param array $origin Instruction element.
2026 * @param string $key Instruction key.
2027 * @return string Target attribute, or ''.
2028 */
2029 private static function replace_target( $origin, $key ) {
2030 foreach ( array( $key . '_replace', $key . '_replace ' ) as $candidate ) {
2031 if ( ! empty( $origin[ $candidate ] ) && is_string( $origin[ $candidate ] ) ) {
2032 return trim( $origin[ $candidate ] );
2033 }
2034 }
2035
2036 return '';
2037 }
2038
2039 /**
2040 * `wdkitai_social_icon_order` -> the visitor's own social URLs in that order, blanks
2041 * where they have none. Mirrors the `wdkitai_social_icon_order` branch of
2042 * replace_elementor_txt().
2043 *
2044 * @param mixed $value Order string ("facebook | twitter | ...") or list.
2045 * @param array $site_info Site info.
2046 * @return array
2047 */
2048 private static function social_icon_values( $value, $site_info ) {
2049 $order = is_array( $value ) ? ( $value[0] ?? '' ) : $value;
2050 $links = ( isset( $site_info['social_links'] ) && is_array( $site_info['social_links'] ) ) ? $site_info['social_links'] : array();
2051
2052 $out = array();
2053
2054 foreach ( array_map( 'trim', explode( '|', (string) $order ) ) as $name ) {
2055 $out[] = ( '' !== $name && ! empty( $links[ $name ] ) ) ? (string) $links[ $name ] : '';
2056 }
2057
2058 /* Kept for the write side, which can tell what network each ROW is and so does not
2059 * have to trust that the order above matches the widget's own rows. */
2060 self::$social_by_network = array();
2061
2062 foreach ( $links as $network => $url ) {
2063 if ( is_string( $network ) && is_string( $url ) && '' !== trim( $url ) ) {
2064 self::$social_by_network[ strtolower( trim( $network ) ) ] = $url;
2065 }
2066 }
2067
2068 return $out;
2069 }
2070
2071 /**
2072 * `wdkitai_admin_detail_order` -> [ [display values], [href values] ], from the
2073 * visitor's contact details. Mirrors the `wdkitai_admin_detail_order` branch.
2074 *
2075 * @param mixed $value Order string or list.
2076 * @param array $site_info Site info.
2077 * @return array{0:array,1:array}
2078 */
2079 private static function admin_detail_values( $value, $site_info ) {
2080 $order = is_array( $value ) ? ( $value[0] ?? '' ) : $value;
2081
2082 $text = array();
2083 $link = array();
2084
2085 foreach ( array_map( 'trim', explode( '|', (string) $order ) ) as $name ) {
2086 if ( '' === $name || empty( $site_info[ $name ] ) ) {
2087 continue;
2088 }
2089
2090 $detail = (string) $site_info[ $name ];
2091 $text[] = $detail;
2092
2093 if ( 'phone' === $name ) {
2094 $link[] = 'tel:' . $detail;
2095 } elseif ( 'email' === $name ) {
2096 $link[] = 'mailto:' . $detail;
2097 } else {
2098 $link[] = '#';
2099 }
2100 }
2101
2102 return array( $text, $link );
2103 }
2104
2105 /**
2106 * The logo URL for a `wdkitai_image_description_type = site_logo` instruction, picking
2107 * the variant named by `wdkitai_site_logo` when the visitor supplied one. Mirrors the
2108 * `wdkitai_image_description_type` branch.
2109 *
2110 * @param array $origin Instruction element.
2111 * @param array $site_info Site info.
2112 * @return string
2113 */
2114 private static function site_logo_value( $origin, $site_info ) {
2115 $logo = ( isset( $site_info['logo'] ) && is_array( $site_info['logo'] ) ) ? $site_info['logo'] : array();
2116
2117 $variant = isset( $origin['wdkitai_site_logo'] ) ? (string) $origin['wdkitai_site_logo'] : '';
2118
2119 if ( '' !== $variant && ! empty( $logo[ $variant ] ) ) {
2120 return (string) $logo[ $variant ];
2121 }
2122
2123 return ! empty( $logo['default'] ) ? (string) $logo['default'] : '';
2124 }
2125
2126 /**
2127 * Block-shape counterpart of traverse_for_instructions().
2128 *
2129 * @param mixed $blocks Blocks.
2130 * @param string $current Section currently in scope.
2131 * @param array $section_labels Descriptions.
2132 * @param array $section_map Accumulator (by reference).
2133 * @param array $detail_section Accumulator (by reference).
2134 * @return void
2135 */
2136 private static function traverse_blocks_for_instructions( $blocks, $current, $section_labels, &$section_map, &$detail_section ) {
2137 if ( ! is_array( $blocks ) ) {
2138 return;
2139 }
2140
2141 foreach ( $blocks as $block ) {
2142 if ( ! is_array( $block ) ) {
2143 continue;
2144 }
2145
2146 $attrs = ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) ? $block['attrs'] : array();
2147 $children = ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) ? $block['innerBlocks'] : array();
2148 $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
2149
2150 /* `tpgb/tp-container` -> `tp-container`, matching the JS `type` field. */
2151 $type = ( false !== strpos( $name, '/' ) ) ? substr( $name, strpos( $name, '/' ) + 1 ) : $name;
2152
2153 $is_section = ( 'tp-container' === $type )
2154 && self::js_truthy( isset( $attrs['enable_ai_meta_info'] ) ? $attrs['enable_ai_meta_info'] : null )
2155 && ! empty( $attrs['wdkitai_sectionType'] );
2156
2157 if ( $is_section ) {
2158 $section = (string) $attrs['wdkitai_sectionType'];
2159
2160 if ( 'wdkit_custom' === $section && ! empty( $attrs['wdkitai_conTitle'] ) ) {
2161 $section = (string) $attrs['wdkitai_conTitle'];
2162 }
2163
2164 if ( ! isset( $detail_section[ $section ] ) ) {
2165 $information = isset( $section_labels[ $section ] ) ? $section_labels[ $section ] : 'No description available.';
2166
2167 $detail_section[ $section ] = array(
2168 'information' => $information,
2169 'elements' => array(),
2170 );
2171 $section_map[ $section ] = array(
2172 'information' => $information,
2173 'elements' => array(),
2174 );
2175 }
2176
2177 self::traverse_blocks_for_instructions( $children, $section, $section_labels, $section_map, $detail_section );
2178
2179 continue;
2180 }
2181
2182 if ( 'tp-container' === $type && ! empty( $children ) ) {
2183 /* A layout container: keeps whatever section it is already inside. */
2184 self::traverse_blocks_for_instructions( $children, $current, $section_labels, $section_map, $detail_section );
2185
2186 continue;
2187 }
2188
2189 if ( ! empty( $attrs ) && null !== $current && isset( $section_map[ $current ] ) ) {
2190 $ai = array();
2191
2192 foreach ( $attrs as $key => $value ) {
2193 if ( 0 === strpos( (string) $key, 'wdkitai_' ) ) {
2194 $ai[ (string) $key ] = $value;
2195 }
2196 }
2197
2198 if ( ! empty( $ai ) ) {
2199 $ai['id'] = ! empty( $attrs['block_id'] ) ? (string) $attrs['block_id'] : substr( md5( wp_json_encode( $ai ) ), 0, 8 );
2200
2201 $detail_section[ $current ]['elements'][] = $ai;
2202
2203 $filtered = array();
2204
2205 foreach ( $ai as $key => $value ) {
2206 if ( 'id' === $key ) {
2207 $filtered[ $key ] = $value;
2208 continue;
2209 }
2210
2211 /* A `*_replace` key names the target attribute; it is not a request,
2212 * and neither are the descriptive meta keys. */
2213 if ( in_array( $key, self::$meta_keys, true ) || '_replace' === substr( (string) $key, -8 ) ) {
2214 continue;
2215 }
2216
2217 $filtered[ $key ] = $value;
2218 }
2219
2220 if ( count( $filtered ) > 1 ) {
2221 $section_map[ $current ]['elements'][] = $filtered;
2222 }
2223 }
2224 }
2225
2226 if ( ! empty( $children ) ) {
2227 self::traverse_blocks_for_instructions( $children, $current, $section_labels, $section_map, $detail_section );
2228 }
2229 }
2230 }
2231
2232 /**
2233 * Normalise a whole-site AI document.
2234 *
2235 * Never throws and never returns a partial shape: an unusable input yields an empty
2236 * document, because "no AI content" has to be a safe outcome for the merge to be
2237 * runnable when generation failed.
2238 *
2239 * @param mixed $raw Decoded document, or a JSON string.
2240 * @param string $builder 'elementor'|'gutenberg'.
2241 * @return array Canonical document.
2242 */
2243 public static function normalize_document( $raw, $builder = 'elementor' ) {
2244 if ( is_string( $raw ) ) {
2245 $raw = self::decode_answer( $raw );
2246 }
2247
2248 $builder = ( 'gutenberg' === $builder ) ? 'gutenberg' : 'elementor';
2249
2250 $document = array(
2251 'version' => self::CONTRACT_VERSION,
2252 'builder' => $builder,
2253 'pages' => array(),
2254
2255 /* The untouched per-template answer, kept ONLY for pages `normalize_payload()`
2256 * could not resolve on its own - i.e. the batched browser answer, which is
2257 * still section-keyed and pre-`_replace`. The page importer picks these up and
2258 * runs browser_answer_to_payload() against the template's element tree, which is
2259 * the one thing normalize_document() does not have here. Canonical documents
2260 * (the remote caller's) never populate this. */
2261 'raw_pages' => array(),
2262 'products' => array(),
2263 'posts' => array(),
2264 'taxonomy' => array(
2265 'categories' => array(),
2266 'tags' => array(),
2267 ),
2268 'elements' => array(),
2269 );
2270
2271 if ( ! is_array( $raw ) ) {
2272 return $document;
2273 }
2274
2275 if ( isset( $raw['builder'] ) && in_array( $raw['builder'], array( 'elementor', 'gutenberg' ), true ) ) {
2276 $document['builder'] = $raw['builder'];
2277 $builder = $raw['builder'];
2278 }
2279
2280 $is_document = isset( $raw['pages'] ) || isset( $raw['products'] ) || isset( $raw['posts'] );
2281
2282 /* Legacy input: the whole thing is one page's element list. Round-trip it through
2283 * normalize_payload() rather than reimplementing, so the two can never drift. */
2284 if ( ! $is_document ) {
2285 $document['elements'] = self::normalize_payload( $raw, $builder )['elements'];
2286
2287 return $document;
2288 }
2289
2290 if ( isset( $raw['elements'] ) && is_array( $raw['elements'] ) ) {
2291 $document['elements'] = self::normalize_payload( array( 'elements' => $raw['elements'] ), $builder )['elements'];
2292 }
2293
2294 if ( ! empty( $raw['pages'] ) && is_array( $raw['pages'] ) ) {
2295 $count = 0;
2296
2297 foreach ( $raw['pages'] as $template_id => $page ) {
2298 if ( ++$count > self::MAX_RECORDS ) {
2299 break;
2300 }
2301
2302 /* A list-shaped `pages` carries its own template id per entry; a map keys by
2303 * it. Both appear in practice — the batch endpoint returns the first, a
2304 * hand-built payload tends to the second. */
2305 if ( is_array( $page ) && isset( $page['template_id'] ) ) {
2306 $key = (string) $page['template_id'];
2307 $page = isset( $page['payload'] ) ? $page['payload'] : $page;
2308 } else {
2309 $key = (string) $template_id;
2310 }
2311
2312 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $key );
2313
2314 if ( '' === $key ) {
2315 continue;
2316 }
2317
2318 $normalized = self::normalize_payload( $page, $builder );
2319
2320 $document['pages'][ $key ] = $normalized;
2321
2322 /* Nothing canonical in it. Keep the raw answer so the page importer can
2323 * resolve it with the template tree - bounded, because it is client data. */
2324 if ( empty( $normalized['elements'] ) && ! empty( $page ) && count( $document['raw_pages'] ) < self::MAX_RECORDS ) {
2325 $document['raw_pages'][ $key ] = $page;
2326 }
2327 }
2328 }
2329
2330 if ( ! empty( $raw['products'] ) && is_array( $raw['products'] ) ) {
2331 foreach ( array_slice( array_values( $raw['products'] ), 0, self::MAX_RECORDS ) as $product ) {
2332 $clean = self::normalize_product( $product );
2333
2334 if ( null !== $clean ) {
2335 $document['products'][] = $clean;
2336 }
2337 }
2338 }
2339
2340 if ( ! empty( $raw['posts'] ) && is_array( $raw['posts'] ) ) {
2341 foreach ( array_slice( array_values( $raw['posts'] ), 0, self::MAX_RECORDS ) as $post ) {
2342 $clean = self::normalize_post( $post );
2343
2344 if ( null !== $clean ) {
2345 $document['posts'][] = $clean;
2346 }
2347 }
2348 }
2349
2350 $taxonomy = isset( $raw['taxonomy'] ) && is_array( $raw['taxonomy'] ) ? $raw['taxonomy'] : $raw;
2351
2352 $document['taxonomy']['categories'] = self::term_names( isset( $taxonomy['categories'] ) ? $taxonomy['categories'] : array() );
2353 $document['taxonomy']['tags'] = self::term_names( isset( $taxonomy['tags'] ) ? $taxonomy['tags'] : array() );
2354
2355 return $document;
2356 }
2357
2358 /**
2359 * One page's payload out of a document, by template id.
2360 *
2361 * Returns null — not an empty payload — when the document has nothing for this
2362 * template, because null is what the page importer already treats as "no AI, leave the
2363 * template copy alone".
2364 *
2365 * @param array $document Canonical document.
2366 * @param mixed $template_id Template id.
2367 * @return array|null
2368 */
2369 public static function page_payload( $document, $template_id ) {
2370 if ( empty( $document['pages'] ) || ! is_array( $document['pages'] ) ) {
2371 return null;
2372 }
2373
2374 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $template_id );
2375
2376 if ( '' === $key || empty( $document['pages'][ $key ]['elements'] ) ) {
2377 return null;
2378 }
2379
2380 return $document['pages'][ $key ];
2381 }
2382
2383 /**
2384 * The untouched batched answer for one template, when normalize_document() kept it
2385 * because it was not canonical. Feed this to browser_answer_to_payload() along with the
2386 * template's element tree.
2387 *
2388 * @param array $document Document (as normalize_document() built it).
2389 * @param mixed $template_id Template id.
2390 * @return array|null
2391 */
2392 public static function page_raw_answer( $document, $template_id ) {
2393 if ( empty( $document['raw_pages'] ) || ! is_array( $document['raw_pages'] ) ) {
2394 return null;
2395 }
2396
2397 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $template_id );
2398
2399 if ( '' === $key || empty( $document['raw_pages'][ $key ] ) ) {
2400 return null;
2401 }
2402
2403 return $document['raw_pages'][ $key ];
2404 }
2405
2406 /**
2407 * Normalise one generated product record against the whitelist.
2408 *
2409 * @param mixed $raw Raw record.
2410 * @return array|null Null when there is nothing usable.
2411 */
2412 public static function normalize_product( $raw ) {
2413 if ( ! is_array( $raw ) ) {
2414 return null;
2415 }
2416
2417 $clean = array(
2418 'source_id' => self::source_key( $raw ),
2419 'title' => self::plain( self::first_of( $raw, array( 'title', 'product_title', 'name' ) ), self::MAX_TITLE ),
2420 'description' => self::plain( self::first_of( $raw, array( 'description', 'product_desc', 'short_description' ) ), self::MAX_DESCRIPTION ),
2421 'price' => self::money( self::first_of( $raw, array( 'price', 'product_price', 'regular_price' ) ) ),
2422 'category' => self::term_names( self::first_of( $raw, array( 'category', 'categories', 'product_category' ) ) ),
2423 );
2424
2425 /* Title is the one field without which the record cannot become a product — the
2426 * extracted handler throws on an empty title, so dropping it here is the same
2427 * outcome reported earlier and more legibly. */
2428 if ( '' === $clean['title'] ) {
2429 return null;
2430 }
2431
2432 /* Guards the whitelist honestly rather than by convention: if a field is added to
2433 * $product_fields without being built above, this notices in tests. */
2434 return array_intersect_key( $clean, array_flip( self::$product_fields ) );
2435 }
2436
2437 /**
2438 * Normalise one generated blog-post record against the whitelist.
2439 *
2440 * `source_id` is the post *template* id — the browser matches `posts[].id` against
2441 * `temp.id` from the post kit, so the template id is already the stable key and there is
2442 * no need to invent one.
2443 *
2444 * @param mixed $raw Raw record.
2445 * @return array|null Null when there is nothing usable.
2446 */
2447 public static function normalize_post( $raw ) {
2448 if ( ! is_array( $raw ) ) {
2449 return null;
2450 }
2451
2452 $clean = array(
2453 'source_id' => self::source_key( $raw ),
2454 'title' => self::plain( self::first_of( $raw, array( 'title', 'post_title' ) ), self::MAX_TITLE ),
2455 'category' => self::term_names( self::first_of( $raw, array( 'category', 'categories' ) ) ),
2456 'tags' => self::term_names( self::first_of( $raw, array( 'tags', 'tag' ) ) ),
2457 );
2458
2459 /* Unlike a product, a post record with only taxonomy is still useful — the template
2460 * supplies the title in that case. So the test is "anything at all". */
2461 if ( '' === $clean['source_id'] && '' === $clean['title'] && empty( $clean['category'] ) && empty( $clean['tags'] ) ) {
2462 return null;
2463 }
2464
2465 return array_intersect_key( $clean, array_flip( self::$post_fields ) );
2466 }
2467
2468 /**
2469 * A stable, filesystem- and meta-safe key from whatever id a record carries.
2470 *
2471 * @param array $raw Raw record.
2472 * @return string Empty string when the record has no id.
2473 */
2474 private static function source_key( $raw ) {
2475 foreach ( array( 'source_id', 'id', 'template_id' ) as $key ) {
2476 if ( isset( $raw[ $key ] ) && ( is_string( $raw[ $key ] ) || is_numeric( $raw[ $key ] ) ) ) {
2477 $value = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $raw[ $key ] );
2478
2479 if ( '' !== $value ) {
2480 return substr( $value, 0, 64 );
2481 }
2482 }
2483 }
2484
2485 return '';
2486 }
2487
2488 /**
2489 * First present value among several accepted key spellings.
2490 *
2491 * @param array $raw Raw record.
2492 * @param string[] $keys Keys in preference order.
2493 * @return mixed|null
2494 */
2495 private static function first_of( $raw, $keys ) {
2496 foreach ( $keys as $key ) {
2497 if ( isset( $raw[ $key ] ) ) {
2498 return $raw[ $key ];
2499 }
2500 }
2501
2502 return null;
2503 }
2504
2505 /**
2506 * Plain text, length-capped. Generated copy is untrusted input like any other.
2507 *
2508 * @param mixed $value Raw value.
2509 * @param int $limit Character cap.
2510 * @return string
2511 */
2512 private static function plain( $value, $limit ) {
2513 if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
2514 return '';
2515 }
2516
2517 $value = wp_strip_all_tags( (string) $value );
2518 $value = trim( preg_replace( '/\s+/u', ' ', $value ) );
2519
2520 return ( function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $limit ) : substr( $value, 0, $limit ) );
2521 }
2522
2523 /**
2524 * A non-negative price. Anything unparseable becomes 0, which the product importer
2525 * reports as a failed record rather than creating a free product.
2526 *
2527 * @param mixed $value Raw value.
2528 * @return float
2529 */
2530 private static function money( $value ) {
2531 if ( is_string( $value ) ) {
2532 $value = preg_replace( '/[^0-9.\-]/', '', $value );
2533 }
2534
2535 if ( ! is_numeric( $value ) ) {
2536 return 0.0;
2537 }
2538
2539 $value = (float) $value;
2540
2541 return $value > 0 ? round( $value, 2 ) : 0.0;
2542 }
2543
2544 /**
2545 * Clean a list of term names.
2546 *
2547 * Accepts the two shapes that actually turn up: plain strings, and the
2548 * `{term_id, name}` objects `import_taxonomy` hands back.
2549 *
2550 * @param mixed $value Raw list.
2551 * @return string[]
2552 */
2553 private static function term_names( $value ) {
2554 if ( ! is_array( $value ) ) {
2555 return array();
2556 }
2557
2558 $names = array();
2559
2560 foreach ( $value as $item ) {
2561 if ( is_array( $item ) && isset( $item['name'] ) ) {
2562 $item = $item['name'];
2563 }
2564
2565 $name = self::plain( $item, 100 );
2566
2567 if ( '' !== $name && ! in_array( $name, $names, true ) ) {
2568 $names[] = $name;
2569 }
2570
2571 if ( count( $names ) >= self::MAX_TERMS ) {
2572 break;
2573 }
2574 }
2575
2576 return $names;
2577 }
2578
2579 /*
2580 |--------------------------------------------------------------------------
2581 | 5. Payload store (.ai.json)
2582 |--------------------------------------------------------------------------
2583 */
2584
2585 /**
2586 * Directory holding a session's AI payloads.
2587 *
2588 * Lives under the plugin's existing uploads directory (WDKIT_BUILDER_PATH) rather than
2589 * a new location, so it inherits whatever the site already permits there.
2590 *
2591 * @param string $session_id Session id.
2592 * @return string Absolute path with trailing slash, or '' when unavailable.
2593 */
2594 public static function payload_dir( $session_id ) {
2595 $session_id = Wdkit_Import_Session::sanitize_id( $session_id );
2596
2597 if ( '' === $session_id || ! defined( 'WDKIT_BUILDER_PATH' ) ) {
2598 return '';
2599 }
2600
2601 return trailingslashit( WDKIT_BUILDER_PATH ) . 'ai-sessions/' . $session_id . '/';
2602 }
2603
2604 /**
2605 * Path of one template's payload file.
2606 *
2607 * @param string $session_id Session id.
2608 * @param string $template_id Template id.
2609 * @return string
2610 */
2611 public static function payload_path( $session_id, $template_id ) {
2612 $dir = self::payload_dir( $session_id );
2613
2614 if ( '' === $dir ) {
2615 return '';
2616 }
2617
2618 $template_id = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $template_id );
2619
2620 if ( '' === $template_id ) {
2621 return '';
2622 }
2623
2624 return $dir . $template_id . '.ai.json';
2625 }
2626
2627 /**
2628 * Store one template's canonical payload.
2629 *
2630 * A file rather than an option because these are write-once, read-once blobs that can
2631 * run to hundreds of kilobytes for a large page — exactly what the options table is
2632 * the wrong home for.
2633 *
2634 * @param string $session_id Session id.
2635 * @param string $template_id Template id.
2636 * @param array $payload Canonical payload.
2637 * @return bool
2638 */
2639 public static function save_payload( $session_id, $template_id, $payload ) {
2640 $path = self::payload_path( $session_id, $template_id );
2641
2642 if ( '' === $path ) {
2643 return false;
2644 }
2645
2646 $dir = dirname( $path );
2647
2648 if ( ! wp_mkdir_p( $dir ) ) {
2649 return false;
2650 }
2651
2652 $json = wp_json_encode( $payload );
2653
2654 if ( false === $json ) {
2655 return false;
2656 }
2657
2658 return false !== file_put_contents( $path, $json ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
2659 }
2660
2661 /**
2662 * Read one template's payload back.
2663 *
2664 * @param string $session_id Session id.
2665 * @param string $template_id Template id.
2666 * @return array|null Canonical payload, or null when absent/unreadable.
2667 */
2668 public static function load_payload( $session_id, $template_id ) {
2669 $path = self::payload_path( $session_id, $template_id );
2670
2671 if ( '' === $path || ! file_exists( $path ) ) {
2672 return null;
2673 }
2674
2675 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents
2676
2677 if ( false === $raw ) {
2678 return null;
2679 }
2680
2681 $decoded = json_decode( $raw, true );
2682
2683 return is_array( $decoded ) ? $decoded : null;
2684 }
2685
2686 /**
2687 * Is a payload ready for this template?
2688 *
2689 * @param string $session_id Session id.
2690 * @param string $template_id Template id.
2691 * @return bool
2692 */
2693 public static function has_payload( $session_id, $template_id ) {
2694 $path = self::payload_path( $session_id, $template_id );
2695
2696 return '' !== $path && file_exists( $path );
2697 }
2698
2699 /**
2700 * Remove a session's payload directory once the run is done.
2701 *
2702 * @param string $session_id Session id.
2703 * @return void
2704 */
2705 public static function clear_payloads( $session_id ) {
2706 $dir = self::payload_dir( $session_id );
2707
2708 if ( '' === $dir || ! is_dir( $dir ) ) {
2709 return;
2710 }
2711
2712 foreach ( (array) glob( $dir . '*.ai.json' ) as $file ) {
2713 if ( is_file( $file ) ) {
2714 wp_delete_file( $file );
2715 }
2716 }
2717
2718 /** only removes it when empty, which is the behaviour we want */
2719 @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2720 }
2721 }
2722 }
2723