PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 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 All 130 releases
wdesignkit / includes / admin / import / class-wdkit-page-importer.php

class-wdkit-page-importer.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.1, at includes/admin/import/class-wdkit-page-importer.php

2,672 lines 107.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page / section importer — the builder-specific half of the PHP runner.
4 *
5 * Keeps the runner free of builder conditionals. Two paths, one contract:
6 *
7 * Elementor content IS the element tree (JSON). Merge in place, re-encode, store in
8 * `_elementor_data`. Lossless.
9 *
10 * Gutenberg content is block markup with attributes in HTML comments. Round trip via
11 * WordPress's own parse_blocks() / serialize_blocks() — NOT the regex parser
12 * from import_loader.js, so PHP keeps a single block representation and there
13 * is no JSON-escaping mismatch to get wrong.
14 *
15 * Everything reusable is reused rather than reimplemented:
16 *
17 * Wdkit_Api_Call::wdkit_media_import() already public, takes ($content, $editor)
18 * WDKIT_Nexter_Block_Processor::run() Nexter block normalisation
19 * Wdkit_Api_Call::replace_unicode_glitch() the same unicode fix the AJAX import applies
20 * Wdkit_Import_Media discovery / matching / substitution
21 * Wdkit_Ai_Content merge
22 *
23 * The AJAX action `import_page_section` is untouched and still does its own thing; this is a
24 * parallel path with the same building blocks.
25 *
26 * @package Wdesignkit
27 * @since 2.6.5
28 */
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 if ( ! class_exists( 'Wdkit_Page_Importer' ) ) {
35
36 /**
37 * Imports one prepared template as a WordPress post.
38 */
39 class Wdkit_Page_Importer {
40
41 /**
42 * Validated import context.
43 *
44 * @var array
45 */
46 private $context;
47
48 /**
49 * Session id, used to locate AI payloads.
50 *
51 * @var string
52 */
53 private $session_id;
54
55 /**
56 * Credits the cloud reported for the template currently being transformed.
57 *
58 * @var array<string,float>
59 */
60 private $credits = array();
61
62 /**
63 * Every template's payload for the kit being imported, keyed by template id.
64 *
65 * The whole kit in ONE cloud round trip, which is the single biggest thing the browser
66 * importer does that this did not. fetch() below used to call `import_template` per
67 * template: 13 templates meant 13 sequential requests to the cloud, each one assembling
68 * and shipping one file, and every one of them on the critical path. The cloud has had a
69 * route that assembles the entire kit in one response since the bundle work
70 * (`v2/generate_kit_site_bundle`), and it ships each template's file byte-for-byte the
71 * way the per-template route does - so the payload each template gets here is identical
72 * and nothing downstream can tell the difference.
73 *
74 * Null until primed. An empty array means "primed and the kit was not available", which
75 * is remembered so a failed prime is not retried once per template; fetch() then falls
76 * back to the per-template route and the import still completes, just as slowly as it
77 * used to.
78 *
79 * @var array<string,array>|null
80 */
81 private static $bundle = null;
82
83 /**
84 * Kit id $bundle holds, so a second kit in the same process re-primes.
85 *
86 * @var string
87 */
88 private static $bundle_kit = '';
89
90 /**
91 * Per-template AI copy, fetched for the whole kit in one batched cloud call.
92 *
93 * Keyed by template id, each entry the same shape wdkit_generate_ai_content_data()
94 * returns for a single template, so generate_payload() can consume either without
95 * branching downstream. Populated by prime_ai_batch(); empty when priming was skipped,
96 * failed, or the template's own entry was missing from the batch answer — in every one
97 * of those cases generate_payload() falls back to its original per-template call, so a
98 * partial or failed batch never stalls an import, only slows that one page back to what
99 * it cost before this cache existed.
100 *
101 * @var array<string,array>
102 */
103 private static $ai_batch_cache = array();
104
105 /**
106 * Kit id $ai_batch_cache holds, mirroring $bundle_kit.
107 *
108 * @var string
109 */
110 private static $ai_batch_kit = '';
111
112 /**
113 * Transient key for one template's batched answer in one session.
114 *
115 * The in-memory cache only lives for the request that made the batch call. A run is
116 * sliced across requests (and lanes), so every page that landed in a later slice used
117 * to throw its batched answer away and call the cloud again, one page at a time.
118 *
119 * @param string $session_id Session id.
120 * @param string $template_id Template id.
121 * @return string
122 */
123 private static function batch_answer_key( $session_id, $template_id ) {
124 return 'wdkit_aib_' . md5( $session_id . '|' . $template_id );
125 }
126
127 /**
128 * The batched answer for a template, from this request's memory or a sibling's save.
129 *
130 * @param string $template_id Template id.
131 * @param string $session_id Session id.
132 * @return array|null
133 */
134 private static function batched_answer( $template_id, $session_id = '' ) {
135 $template_id = (string) $template_id;
136
137 if ( isset( self::$ai_batch_cache[ $template_id ] ) ) {
138 return self::$ai_batch_cache[ $template_id ];
139 }
140
141 if ( '' === (string) $session_id ) {
142 return null;
143 }
144
145 $saved = get_transient( self::batch_answer_key( (string) $session_id, $template_id ) );
146
147 if ( ! is_array( $saved ) ) {
148 return null;
149 }
150
151 self::$ai_batch_cache[ $template_id ] = $saved;
152
153 return $saved;
154 }
155
156 /**
157 * The kit's site-level settings, lifted off the bundle.
158 *
159 * Every template in a kit carries the same `site_settings` block in its content, and it
160 * is where the kit's container width lives — `{md,unit,xs}` for Gutenberg,
161 * `{container_width,globals,body_background_color}` for Elementor. The browser path
162 * reads it off the first template that has one (`update_site_globals(content.site_settings)`
163 * in import_loader.js) and the runner had no equivalent, so an imported site fell back to
164 * the builder's default container width — 1140px instead of the kit's 1240px.
165 *
166 * Captured here because prime_bundle() is the one place that already decodes every
167 * template's content, so this costs nothing extra.
168 *
169 * @since 2.7.2
170 *
171 * @var array
172 */
173 private static $bundle_site_settings = array();
174
175 /**
176 * The kit's site-level settings from the primed bundle.
177 *
178 * Empty when the bundle was not primed or the kit shipped none, which callers treat as
179 * "nothing to apply" rather than as an error.
180 *
181 * @since 2.7.2
182 *
183 * @return array
184 */
185 public static function bundle_site_settings() {
186 return is_array( self::$bundle_site_settings ) ? self::$bundle_site_settings : array();
187 }
188
189 /**
190 * @param array $context Validated context.
191 * @param string $session_id Session id.
192 */
193 public function __construct( $context, $session_id = '' ) {
194 $this->context = is_array( $context ) ? $context : array();
195 $this->session_id = (string) $session_id;
196 }
197
198 /**
199 * Tell the session this lane is still working on the template.
200 *
201 * stage_content() claims `page_<id>` before calling import() and the claim expires so a
202 * dead lane does not lock the template out of the retry. But import() can outlast that
203 * TTL on a heavy page — every image still remote, a slow cloud save — and then a second
204 * lane takes the claim over and imports it again. Renewing the claim at each phase
205 * boundary keeps a live-but-slow lane's ownership intact while still letting a genuinely
206 * dead one's claim go stale. touch_claim() only refreshes a claim that exists, so
207 * naming both possible step keys is harmless — a blog post is claimed as `post_<id>`
208 * (Wdkit_Import_Posts), a page as `page_<id>` (stage_content). No-op off the runner.
209 *
210 * @param array $template Template record.
211 * @return void
212 */
213 private function heartbeat( $template ) {
214 if ( '' === $this->session_id || empty( $template['id'] ) || ! class_exists( 'Wdkit_Import_Session' ) ) {
215 return;
216 }
217
218 Wdkit_Import_Session::touch_claim( $this->session_id, 'page_' . $template['id'] );
219 Wdkit_Import_Session::touch_claim( $this->session_id, 'post_' . $template['id'] );
220 }
221
222 /**
223 * Pull the whole kit down in one request, before any template is imported.
224 *
225 * Safe to call repeatedly: the decoded map is held for the process, and the underlying
226 * fetch parks the assembled bundle in a transient (`widgets_only`) so a later stage
227 * request in the same run reads that copy instead of asking the cloud again. So a run
228 * sliced across several requests still pays for one cloud assembly.
229 *
230 * Never throws and never fails the import. Anything that goes wrong here leaves the
231 * per-template path in place.
232 *
233 * @since 2.7.2
234 *
235 * @param array $context Validated import context.
236 * @param string $builder 'elementor'|'gutenberg'.
237 * @param array $templates Template records for the kit.
238 * @return int Templates the bundle supplied.
239 */
240 public static function prime_bundle( $context, $builder, $templates ) {
241 $kit_id = isset( $context['kit_id'] ) ? (string) $context['kit_id'] : '';
242
243 if ( '' === $kit_id || empty( $templates ) || ! is_array( $templates ) ) {
244 return 0;
245 }
246
247 if ( null !== self::$bundle && self::$bundle_kit === $kit_id ) {
248 return count( self::$bundle );
249 }
250
251 self::$bundle = array();
252 self::$bundle_kit = $kit_id;
253 self::$bundle_site_settings = array();
254
255 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_site_bundle_for' ) ) {
256 return 0;
257 }
258
259 /* The same token the per-template fetch resolves, resolved the same way - this
260 * changes which cloud route is called, not who is calling it, so the credit and
261 * entitlement accounting behind it is untouched. */
262 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
263
264 /* A sandbox has no signed-in account, but it does have a poll token the cloud can
265 * authenticate the bundle on - so an empty JWT alone is no longer a reason to skip
266 * the bundle and fall back to per-template fetches. */
267 if ( ! function_exists( 'wdkit_kit_import_can_authenticate' ) || ! wdkit_kit_import_can_authenticate( $token ) ) {
268 return 0;
269 }
270
271 $payload = array();
272
273 foreach ( $templates as $template ) {
274 if ( empty( $template['id'] ) ) {
275 continue;
276 }
277
278 $payload[] = array(
279 'id' => (int) $template['id'],
280 'title' => isset( $template['title'] ) ? (string) $template['title'] : '',
281 'wp_post_type' => isset( $template['wp_post_type'] ) ? (string) $template['wp_post_type'] : 'page',
282 '_editor' => $builder,
283 );
284 }
285
286 if ( empty( $payload ) ) {
287 return 0;
288 }
289
290 $t_bundle = microtime( true );
291
292 $bundle = Wdkit_Api_Call::get_instance()->wdkit_site_bundle_for(
293 array(
294 'template_ids' => $payload,
295 'website_kit' => $kit_id,
296 'editor' => $builder,
297 'builder' => $builder,
298 // Park the assembled copy for the rest of this run's stage requests.
299 'widgets_only' => true,
300 ),
301 $token
302 );
303
304 if ( is_wp_error( $bundle ) || empty( $bundle['success'] ) ) {
305 if ( class_exists( 'Wdkit_Import_Log' ) ) {
306 Wdkit_Import_Log::add(
307 'bundle',
308 array(
309 'ok' => 0,
310 'ms' => (int) round( ( microtime( true ) - $t_bundle ) * 1000 ),
311 'message' => is_wp_error( $bundle ) ? $bundle->get_error_message() : 'bundle unavailable',
312 )
313 );
314 }
315
316 return 0;
317 }
318
319 foreach ( array( 'sections', 'pages' ) as $group ) {
320 if ( empty( $bundle[ $group ] ) || ! is_array( $bundle[ $group ] ) ) {
321 continue;
322 }
323
324 foreach ( $bundle[ $group ] as $item ) {
325 if ( ! is_array( $item ) || empty( $item['id'] ) || ! isset( $item['content'] ) ) {
326 continue;
327 }
328
329 /* The group travels with the item because the importer keys on it: sections
330 * are headers/footers and must be inserted before the pages that reference
331 * them, and only a `page` is eligible to become the front page. */
332 $item['__group'] = $group;
333
334 self::$bundle[ (string) $item['id'] ] = $item;
335
336 /* First template that carries one wins, and the rest are not decoded for it.
337 * Every template in a kit ships the same block, so there is nothing to
338 * reconcile between them — see $bundle_site_settings. */
339 if ( empty( self::$bundle_site_settings ) ) {
340 $decoded = is_string( $item['content'] ) ? json_decode( $item['content'], true ) : $item['content'];
341
342 if ( is_array( $decoded ) && ! empty( $decoded['site_settings'] ) && is_array( $decoded['site_settings'] ) ) {
343 self::$bundle_site_settings = $decoded['site_settings'];
344 }
345 }
346 }
347 }
348
349 if ( class_exists( 'Wdkit_Import_Log' ) ) {
350 Wdkit_Import_Log::add(
351 'bundle',
352 array(
353 'ok' => 1,
354 'ms' => (int) round( ( microtime( true ) - $t_bundle ) * 1000 ),
355 'templates' => count( self::$bundle ),
356 'cached' => ! empty( $bundle['from_cache'] ) ? 1 : 0,
357 )
358 );
359 }
360
361 return count( self::$bundle );
362 }
363
364 /**
365 * Fetch AI copy for every template in the kit in one batched cloud call, instead of
366 * one `ai/template_import` round trip per template inside the stage_content() loop.
367 *
368 * Must run after prime_bundle() — it reads each template's content off self::$bundle,
369 * which is already decoded there, so this costs one extra request, not one extra fetch.
370 * Skipped outright for a non-AI import, since there is nothing to batch.
371 *
372 * Best-effort and never throws: a template with no `wdkitai_*` instructions, a missing
373 * bundle entry, a missing token, or an outright batch failure simply has no cache entry,
374 * and generate_payload() falls straight back to its original per-template call for that
375 * one page — exactly the path every page used before this cache existed. A slow or
376 * failed batch therefore never stalls or breaks an import; it only gives back the speed
377 * this was written for.
378 *
379 * `self::$ai_batch_kit` only remembers this PHP process's own run — a resumable import
380 * is sliced across separate WP-Cron requests, each a fresh process with this reset back
381 * to its initial state, so that guard alone never once fired across a retry. Every slice
382 * was re-batching AI copy for the WHOLE kit, including templates a previous slice had
383 * already answered and written to disk via generate_payload() -> save_payload() — on a
384 * large kit that batch call alone could run long enough to burn the slice's entire time
385 * budget before a single template import happened, so `out_of_time()` tripped with zero
386 * progress made, every retry, forever. Skipping any template `Wdkit_Ai_Content` already
387 * has a saved payload for (see the `has_payload()` check below) is what makes each retry
388 * batch only the templates still outstanding, so the batch call shrinks — and the run
389 * actually finishes — as slices complete instead of staying kit-sized forever.
390 *
391 * @since 2.7.3
392 *
393 * @param array $context Validated import context.
394 * @param string $builder 'elementor'|'gutenberg'.
395 * @param array $templates Template records for the kit.
396 * @param string $session_id This run's session id, to check which templates already
397 * have a saved AI payload from an earlier slice.
398 * @return int Templates the batch answered for.
399 */
400 /**
401 * Does a section map hold at least one element for the AI to write?
402 *
403 * A template can mark sections for AI and still have nothing in them (Zion's Blog Detail
404 * template: two sections, zero elements - its text is all dynamic post blocks). Such a
405 * map is not empty, so it used to be sent anyway: one paid AI call per import whose
406 * answer could never be placed (`answer_placed_nothing`).
407 *
408 * @param mixed $section_map Section map.
409 * @return bool
410 */
411 private static function has_ai_elements( $section_map ) {
412 foreach ( (array) $section_map as $section ) {
413 if ( is_array( $section ) && ! empty( $section['elements'] ) ) {
414 return true;
415 }
416 }
417
418 return false;
419 }
420
421 /**
422 * prime_ai_batch() for templates from a kit other than the one being imported.
423 *
424 * Blog posts come from a fixed post kit, so they are never in the run's bundle and the
425 * page batch never covers them: each post then made its own AI call, one after another
426 * (~80s for six). This borrows the static bundle for that kit just long enough to batch
427 * their copy, then puts the page bundle back, so the posts still import exactly as
428 * before and only their AI answer comes from the batch.
429 *
430 * @param array $context Context whose kit_id is the other kit.
431 * @param string $builder 'elementor'|'gutenberg'.
432 * @param array $templates That kit's template records.
433 * @param string $session_id Session id.
434 * @return int Templates the batch answered for.
435 */
436 public static function prime_ai_batch_for_kit( $context, $builder, $templates, $session_id = '' ) {
437 $saved = array( self::$bundle, self::$bundle_kit, self::$bundle_site_settings, self::$ai_batch_cache, self::$ai_batch_kit );
438
439 $answered = 0;
440 $fresh = array();
441
442 try {
443 self::$bundle = null;
444 self::$ai_batch_kit = '';
445
446 if ( self::prime_bundle( $context, $builder, $templates ) > 0 ) {
447 $answered = self::prime_ai_batch( $context, $builder, $templates, $session_id );
448 $fresh = self::$ai_batch_cache;
449 }
450 } finally {
451 list( self::$bundle, self::$bundle_kit, self::$bundle_site_settings, self::$ai_batch_cache, self::$ai_batch_kit ) = $saved;
452 self::$ai_batch_cache = $fresh + (array) self::$ai_batch_cache;
453 }
454
455 return $answered;
456 }
457
458 public static function prime_ai_batch( $context, $builder, $templates, $session_id = '' ) {
459 $kit_id = isset( $context['kit_id'] ) ? (string) $context['kit_id'] : '';
460
461 if ( '' === $kit_id || empty( $templates ) || ! is_array( $templates ) ) {
462 return 0;
463 }
464
465 /* Only ever skips a second call within the SAME request — see the docblock above
466 * for why this cannot and does not stand in for the has_payload() check per slice. */
467 if ( null !== self::$ai_batch_kit && self::$ai_batch_kit === $kit_id ) {
468 return count( self::$ai_batch_cache );
469 }
470
471 self::$ai_batch_cache = array();
472 self::$ai_batch_kit = $kit_id;
473
474 if ( 'ai_import' !== ( $context['import_type'] ?? '' ) || ! empty( $context['wirefram_import'] ) ) {
475 return 0;
476 }
477
478 if ( empty( self::$bundle ) || ! class_exists( 'Wdkit_Import_temp_Ajax' ) || ! class_exists( 'Wdkit_Ai_Content' ) ) {
479 return 0;
480 }
481
482 $instance = Wdkit_Import_temp_Ajax::get_instance();
483
484 if ( ! method_exists( $instance, 'wdkit_generate_ai_content_batch_data' ) ) {
485 return 0;
486 }
487
488 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
489
490 /* An empty JWT alone is no longer a reason to skip AI generation - a sandbox site
491 * authenticates the same cloud route with its poll token instead (see
492 * wdkit_generate_ai_content_batch_data(), which carries it alongside this token). */
493 if ( ! function_exists( 'wdkit_kit_import_can_authenticate' ) || ! wdkit_kit_import_can_authenticate( $token ) ) {
494 return 0;
495 }
496
497 $t_batch = microtime( true );
498 $pages = array();
499
500 foreach ( $templates as $template ) {
501 $template_id = isset( $template['id'] ) ? (string) $template['id'] : '';
502
503 if ( '' === $template_id || empty( self::$bundle[ $template_id ] ) ) {
504 continue;
505 }
506
507 /* Already answered in an earlier slice — re-requesting it would cost real
508 * time on every retry for nothing, generate_payload() already reads this
509 * saved copy instead of calling out at all. See the docblock above. */
510 if ( '' !== $session_id && Wdkit_Ai_Content::has_payload( $session_id, $template_id ) ) {
511 continue;
512 }
513
514 if ( null !== self::batched_answer( $template_id, $session_id ) ) {
515 continue;
516 }
517
518 $item = self::$bundle[ $template_id ];
519 $decoded = is_string( $item['content'] ) ? json_decode( $item['content'], true ) : $item['content'];
520
521 if ( ! is_array( $decoded ) || empty( $decoded['content'] ) ) {
522 continue;
523 }
524
525 $content = $decoded['content'];
526
527 /* Same two shapes, two walkers as generate_payload() — a block tree run through
528 * the Elementor walker finds nothing and silently drops the template's AI copy. */
529 if ( 'gutenberg' === $builder ) {
530 $tree = function_exists( 'parse_blocks' ) ? parse_blocks( is_string( $content ) ? $content : '' ) : array();
531 } else {
532 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
533 }
534
535 if ( ! is_array( $tree ) || empty( $tree ) ) {
536 continue;
537 }
538
539 if ( 'gutenberg' === $builder ) {
540 $section_map = Wdkit_Ai_Content::extract_ai_pair_gutenberg( $tree )['request'];
541 } else {
542 $section_map = Wdkit_Ai_Content::extract_ai_object( $tree )['sectionMap'];
543 }
544
545 if ( ! self::has_ai_elements( $section_map ) ) {
546 continue;
547 }
548
549 $page_type = 'custom';
550 $settings = ( isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ) ? $decoded['settings'] : array();
551
552 if ( ! empty( $settings['wdkitai_page_type'] ) && is_string( $settings['wdkitai_page_type'] ) ) {
553 $page_type = $settings['wdkitai_page_type'];
554 }
555
556 $pages[] = array(
557 'id' => $template_id,
558 'text_array' => array( $page_type => $section_map ),
559 );
560 }
561
562 if ( empty( $pages ) ) {
563 return 0;
564 }
565
566 $info = ( ! empty( $context['site_info'] ) && is_array( $context['site_info'] ) ) ? $context['site_info'] : array();
567
568 $response = $instance->wdkit_generate_ai_content_batch_data(
569 array(
570 'pages' => $pages,
571 'type' => isset( $context['site_type'] ) ? $context['site_type'] : '',
572 'title' => isset( $info['site_name'] ) ? $info['site_name'] : '',
573 'language' => isset( $context['site_lang'] ) ? $context['site_lang'] : 'english',
574 'agency' => isset( $context['site_agency'] ) ? $context['site_agency'] : '',
575 'description' => isset( $context['site_description'] ) ? $context['site_description'] : '',
576 'builder' => $builder,
577 'token' => $token,
578 )
579 );
580
581 if ( class_exists( 'Wdkit_Import_Log' ) ) {
582 Wdkit_Import_Log::add(
583 'ai_batch',
584 array(
585 'ok' => empty( $response['success'] ) ? 0 : 1,
586 'ms' => (int) round( ( microtime( true ) - $t_batch ) * 1000 ),
587 'requested' => count( $pages ),
588 'answered' => ( ! empty( $response['results'] ) && is_array( $response['results'] ) ) ? count( $response['results'] ) : 0,
589 )
590 );
591 }
592
593 if ( empty( $response['success'] ) || empty( $response['results'] ) || ! is_array( $response['results'] ) ) {
594 return 0;
595 }
596
597 foreach ( $response['results'] as $result ) {
598 if ( ! is_array( $result ) || empty( $result['id'] ) ) {
599 continue;
600 }
601
602 self::$ai_batch_cache[ (string) $result['id'] ] = $result;
603
604 if ( '' !== $session_id ) {
605 set_transient( self::batch_answer_key( $session_id, (string) $result['id'] ), $result, HOUR_IN_SECONDS );
606 }
607 }
608
609 return count( self::$ai_batch_cache );
610 }
611
612 /**
613 * Does this template already have a free answer sitting in the batch cache?
614 *
615 * The runner's stage_content() checks this before applying out_of_time()/
616 * STAGE_ITEM_CAP to a template (ClickUp 14ynqxywpec's stall investigation): the batch
617 * call this class-level cache holds already paid the network cost — for every page
618 * template_import_batch answered, generate_payload() below reads the answer straight
619 * out of memory, no cloud round trip. Gating that page behind the SAME per-page time
620 * budget a page needing a fresh network call gets is what caused the stall: a 14-page
621 * kit's batch call alone (bounded by its slowest page, by design — see
622 * wdkit_generate_ai_content_batch_data()) routinely ran 25-50s, well past the 8s
623 * stage budget, so out_of_time() was already true for template #1 by the time the
624 * loop below started — every page reported pending, none were imported, and nothing
625 * was ever saved for has_payload() to find on the next attempt. The exact same
626 * request happened again, and again, forever. A page this returns true for costs
627 * this request a merge and a DB write, not a network wait, so it is let through
628 * regardless of the clock — the loop still respects out_of_time() for every page
629 * that does NOT have a free answer waiting.
630 *
631 * @since 2.7.3
632 *
633 * @param string $template_id Template id.
634 * @return bool
635 */
636 public static function has_batched_answer( $template_id, $session_id = '' ) {
637 return null !== self::batched_answer( $template_id, $session_id );
638 }
639
640 /**
641 * Every widget and Plus-extension the primed bundle's content uses.
642 *
643 * The PHP side of the browser's enable_bundle_widgets() — it POSTs `wkit_fetch_site_bundle`
644 * with `widgets_only` and gets back this exact shape (class-api.php wdkit_bundle_widget_
645 * manifest()). The runner has no browser to make that call, so it walks the bundle it
646 * already holds. Must run BEFORE any template is saved: Elementor's Document::save() drops
647 * every node whose widgetType is not registered, silently, and a kit built almost entirely
648 * of tp-* widgets comes out as empty containers on a site where The Plus Addons ships them
649 * off by default (which is every fresh install).
650 *
651 * @since 2.7.2
652 *
653 * @return array{widgets:string[],extensions:string[]}
654 */
655 public static function bundle_widget_manifest() {
656 $widgets = array();
657 $extensions = array();
658
659 /* The three section/column extensions the browser's manifest also detects, keyed by
660 * the settings flag each one leaves in the content JSON. */
661 $extension_keys = array(
662 'sc_link_switch' => 'plus_section_column_link',
663 'scwbf_options' => 'plus_glass_morphism',
664 'seh_switch' => 'plus_equal_height',
665 );
666
667 foreach ( (array) self::$bundle as $item ) {
668 if ( ! is_array( $item ) || ! isset( $item['content'] ) ) {
669 continue;
670 }
671
672 /* Fully unwrap the item the way personalize_bundle_item() does: the cloud ships
673 * `content` as a JSON string of `{content, file_type}`, and its inner `content`
674 * is itself sometimes a JSON string. Re-encoding the decoded tree gives one flat
675 * layer of quoting for the regex, whether the cloud double-encoded it or not. */
676 $decoded = is_string( $item['content'] ) ? json_decode( $item['content'], true ) : $item['content'];
677 $tree = ( is_array( $decoded ) && isset( $decoded['content'] ) ) ? $decoded['content'] : $decoded;
678 $tree = is_string( $tree ) ? json_decode( $tree, true ) : $tree;
679 $json = is_array( $tree ) ? (string) wp_json_encode( $tree ) : (string) ( is_string( $item['content'] ) ? $item['content'] : wp_json_encode( $item['content'] ) );
680
681 if ( preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $json, $m ) ) {
682 $widgets = array_merge( $widgets, $m[1] );
683 }
684
685 foreach ( $extension_keys as $flag => $extension ) {
686 if ( false !== strpos( $json, '"' . $flag . '"' ) ) {
687 $extensions[] = $extension;
688 }
689 }
690 }
691
692 return array(
693 'widgets' => array_values( array_unique( array_filter( $widgets, 'is_string' ) ) ),
694 'extensions' => array_values( array_unique( $extensions ) ),
695 );
696 }
697
698 /**
699 * Block names a primed Gutenberg bundle's content uses, `tpgb/` prefix stripped.
700 *
701 * The Gutenberg counterpart of bundle_widget_manifest(): The Plus Addons ships its
702 * blocks disabled too, and an unregistered block renders unstyled rather than being
703 * dropped — still a broken page.
704 *
705 * @since 2.7.2
706 *
707 * @return string[]
708 */
709 public static function bundle_block_names() {
710 $names = array();
711
712 foreach ( (array) self::$bundle as $item ) {
713 if ( ! is_array( $item ) || ! isset( $item['content'] ) ) {
714 continue;
715 }
716
717 $markup = is_string( $item['content'] ) ? $item['content'] : '';
718
719 if ( '' === $markup ) {
720 $decoded = is_array( $item['content'] ) ? $item['content'] : array();
721 $markup = isset( $decoded['content'] ) && is_string( $decoded['content'] ) ? $decoded['content'] : '';
722 } else {
723 $decoded = json_decode( $markup, true );
724 $markup = ( is_array( $decoded ) && isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) ? $decoded['content'] : $markup;
725 }
726
727 if ( preg_match_all( '/<!--\s+wp:(tpgb\/[a-z0-9-]+)/', $markup, $m ) ) {
728 foreach ( $m[1] as $name ) {
729 $names[] = str_replace( 'tpgb/', '', $name );
730 }
731 }
732 }
733
734 return array_values( array_unique( $names ) );
735 }
736
737 /**
738 * Import one template through the browser flow's own engine.
739 *
740 * ── What this replaces, and why ─────────────────────────────────────────────
741 *
742 * transform()/store() below are a second implementation of an import: they fetch a
743 * template, merge, substitute images and write `_elementor_data` by hand. The browser
744 * flow does none of that itself — it posts the kit to wdkit_import_site_bundle_data(),
745 * which routes every template through Elementor's own documents->create()/save() and
746 * the Nexter block processor, defers media, re-keys element ids, builds block CSS
747 * server-side, and remaps theme-builder conditions.
748 *
749 * Measured on the same 15-template kit: the hand-rolled path took 71s (Gutenberg) and
750 * 139s (Elementor); the engine takes ~8s. Speed was only half of it — the hand-rolled
751 * path also never localised Elementor media at all, and skipped the element-id re-keying
752 * that stops two imported pages sharing ids.
753 *
754 * So this hands the engine ONE item and keeps everything the runner is actually for:
755 * the per-template session claim, the completion record, the retry ceiling and the
756 * error classification all still happen in stage_content around this call.
757 *
758 * `session` is passed and `finalize` deliberately is not, which is how the engine is
759 * told this is one chunk of a larger run: it imports the item and returns, leaving the
760 * front-page choice, the nav menu and the kit finalisation to the runner's own later
761 * stages rather than redoing them once per template.
762 *
763 * @since 2.7.2
764 *
765 * @param array $template Template record.
766 * @param array $overrides Optional {title, categories, tags, featured_image}.
767 * @return array|null Page record, or null when this template is not in the bundle.
768 * @throws Wdkit_Import_Exception When the engine reports the item failed.
769 */
770 private function import_via_bundle( $template, $overrides = array() ) {
771 $template_id = isset( $template['id'] ) ? (string) $template['id'] : '';
772
773 if ( '' === $template_id || empty( self::$bundle[ $template_id ] ) ) {
774 return null;
775 }
776
777 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_import_site_bundle_data' ) ) {
778 return null;
779 }
780
781 $item = self::$bundle[ $template_id ];
782 $group = ( isset( $item['__group'] ) && 'sections' === $item['__group'] ) ? 'sections' : 'pages';
783
784 unset( $item['__group'] );
785
786 /* Personalisation - AI copy, the visitor's picked images, team photos, Gutenberg
787 * global tokens - has to happen before the engine sees the content, because the
788 * engine imports whatever it is given. This is the per-template chain the browser
789 * flow runs in transform_bundle_item(); see personalize_bundle_item(). */
790 $item = $this->personalize_bundle_item( $item, $template );
791
792 $this->heartbeat( $template );
793
794 $builder = $this->builder();
795
796 /* A UNIQUE engine session key for THIS call.
797 *
798 * The engine skips its own finalise (menu, front page, widget enable) for any call
799 * that carries a `session` and no `finalize` — which is what the runner wants,
800 * because it runs those itself in stage_finalize. But a session also makes the
801 * engine bank each call's result into a transient and merge it back in on the next
802 * call keyed by that session (class-api.php:7504-7531). A key shared across a run
803 * therefore accumulates every template — and, across a retry or a reused session
804 * id, STALE copies of a template whose post has since been deleted. `imported` then
805 * comes back with the old entry first and the template_id match returns a post id
806 * that no longer exists.
807 *
808 * A key unique per call sidesteps all of it: `$carried` is always empty, `imported`
809 * holds exactly this one insert, and the transient (dropped again below, and 15-min
810 * TTL if that misses) never has anything to race over or go stale. */
811 $bundle_session = ( $this->session_id ? $this->session_id : 'runner' )
812 . '_' . preg_replace( '/[^A-Za-z0-9_]/', '', $template_id )
813 . '_' . substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 10 );
814
815 $response = Wdkit_Api_Call::get_instance()->wdkit_import_site_bundle_data(
816 array(
817 'bundle' => array(
818 'builder' => $builder,
819 $group => array( $item ),
820 ),
821 'builder' => $builder,
822 'defer_media' => true,
823 'custom_meta' => true,
824 'wirefram_import' => ! empty( $this->context['wirefram_import'] ),
825 /* Unique per-call chunk key — see the note above. */
826 'session' => $bundle_session,
827 )
828 );
829
830 /* The engine banked this call's result under $bundle_session and will not touch it
831 * again (no `finalize` chunk is ever sent for this key). Drop it now rather than
832 * leave one transient per template to sit out its 15-minute TTL. */
833 if ( function_exists( 'delete_transient' ) ) {
834 delete_transient( 'wdkit_sb_' . $bundle_session );
835 }
836
837 /* `imported` holds exactly this call's insert now, but keep the template_id match
838 * and the end() fallback: a future response shape that batches differently must
839 * still resolve to THIS call's own result — the LAST matching entry, never the
840 * first, which under a shared key was an earlier (possibly stale) chunk. */
841 $entry = null;
842
843 if ( is_array( $response ) && ! empty( $response['imported'] ) && is_array( $response['imported'] ) ) {
844 foreach ( array_reverse( $response['imported'] ) as $candidate ) {
845 if ( isset( $candidate['template_id'] ) && (string) $candidate['template_id'] === $template_id ) {
846 $entry = $candidate;
847 break;
848 }
849 }
850
851 if ( null === $entry ) {
852 $last = end( $response['imported'] );
853 $entry = ( false !== $last ) ? $last : null;
854 }
855 }
856
857 $this->heartbeat( $template );
858
859 if ( null === $entry || empty( $entry['id'] ) ) {
860 $message = '';
861
862 if ( is_array( $response ) && ! empty( $response['errors'][0]['message'] ) ) {
863 $message = (string) $response['errors'][0]['message'];
864 } elseif ( is_array( $response ) && ! empty( $response['description'] ) ) {
865 $message = (string) $response['description'];
866 }
867
868 throw new Wdkit_Import_Retryable_Exception(
869 '' !== $message ? $message : __( 'The importer could not store this template.', 'wdesignkit' ),
870 'bundle_insert_failed',
871 'store_' . $template_id
872 );
873 }
874
875 $post_id = (int) $entry['id'];
876
877 $this->apply_overrides( $post_id, $overrides );
878
879 if ( ! empty( $overrides['title'] ) && is_string( $overrides['title'] ) ) {
880 wp_update_post(
881 array(
882 'ID' => $post_id,
883 'post_title' => $overrides['title'],
884 'post_name' => sanitize_title( $overrides['title'] ),
885 )
886 );
887 }
888
889 return array(
890 'template_id' => $template_id,
891 'post_id' => $post_id,
892 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '',
893 'url' => isset( $entry['view'] ) ? (string) $entry['view'] : get_permalink( $post_id ),
894 'ai_applied' => isset( $item['__ai_applied'] ) ? (int) $item['__ai_applied'] : 0,
895 'images_replaced' => isset( $item['__images_swapped'] ) ? (int) $item['__images_swapped'] : 0,
896 'credits' => $this->credits,
897 'widget_list' => ( ! empty( $entry['widget_list'] ) && is_array( $entry['widget_list'] ) ) ? $entry['widget_list'] : array(),
898 'global_data' => $this->bundle_global_data( $template_id ),
899 'post_type' => isset( $entry['post_type'] ) ? (string) $entry['post_type'] : 'page',
900 'old_page_id' => isset( $entry['old_page_id'] ) ? (string) $entry['old_page_id'] : '',
901
902 /* wdkit_bundle_insert_item() (class-api.php) already worked out which of this
903 * page's images are still remote and drained Wdkit_Import_Images::$deferred_urls
904 * to build this. Carried through verbatim so import() can hand it to the
905 * background media sweep - without it every page here looks to have nothing
906 * deferred, and the finalize step ends up downloading all of it inline instead
907 * (see the note on the `image_urls` overwrite in import() below).
908 *
909 * Unioned with any picked stock image that was still remote when it was swapped
910 * in - the same thing transform_bundle_item() does with stock_image_urls, so a
911 * substituted image that has not localised yet is still scheduled rather than
912 * left pointing at Pexels. */
913 'image_urls' => array_values(
914 array_unique(
915 array_filter(
916 array_merge(
917 ( isset( $entry['image_urls'] ) && is_array( $entry['image_urls'] ) ) ? $entry['image_urls'] : array(),
918 ( isset( $item['__stock_remote'] ) && is_array( $item['__stock_remote'] ) ) ? $item['__stock_remote'] : array()
919 ),
920 'is_string'
921 )
922 )
923 ),
924
925 /* Images personalize_bundle_item()'s own media walk (image_map(), team photos)
926 * could not localise for this page — same drain as the hand-rolled path in
927 * import() below. See ClickUp 14ynqxywnae. */
928 'failed_images' => ( class_exists( 'Wdkit_Import_Images' ) )
929 ? Wdkit_Import_Images::get_and_clear_failed_downloads()
930 : array(),
931 );
932 }
933
934 /**
935 * Personalise one bundle template the way ai-template-latest's transform_bundle_item()
936 * does, on the item the engine is about to import.
937 *
938 * ── Why this is not just the AI merge ──────────────────────────────────────
939 *
940 * When the runner drives the wizard, the browser hands its bundle straight to the
941 * engine without transforming it - so every per-template step transform_bundle_item()
942 * runs in the browser flow has to happen HERE instead, or it does not happen at all.
943 * The engine (wdkit_import_site_bundle_data / wdkit_bundle_insert_item) already does
944 * media localisation, the Nexter block processor, element-id re-keying, `__globals__`
945 * resolution by `_id`, the theme-builder remap and the nav menu - so this deliberately
946 * does ONLY the parts it does not:
947 *
948 * 1. AI copy - Wdkit_Ai_Content::merge_* (browser: replace_elementor_txt)
949 * 2. Selected images - image_map() stock slots (browser: replaceMediaLinks)
950 * 3. Team photos - image_map() team slots (browser: replace_ai_team), AI only
951 * 4. Gutenberg tokens - Wdkit_Import_Globals::rewrite_kit_references() - the engine
952 * resolves Elementor `__globals__` by `_id` on save, but has no
953 * equivalent for a block's `var(--tpgb-C7)` references, and the
954 * browser only ever remapped those in Extract_gutenberg_global().
955 *
956 * Wireframe is left to the engine (it swaps the item itself, from the `wirefram_import`
957 * flag import_via_bundle() passes) and skips the stock swap accordingly - matching
958 * transform_bundle_item(), where the `wirefram_import` branch also bypasses steps 2-3.
959 *
960 * @param array $item Bundle item.
961 * @param array $template Template record.
962 * @return array The item with its content personalised.
963 */
964 private function personalize_bundle_item( $item, $template ) {
965 $template_id = isset( $template['id'] ) ? (string) $template['id'] : '';
966
967 $decoded = is_string( $item['content'] ) ? json_decode( $item['content'], true ) : $item['content'];
968
969 if ( ! is_array( $decoded ) || ! isset( $decoded['content'] ) ) {
970 return $item;
971 }
972
973 $this->credits = array();
974
975 $file_type = isset( $decoded['file_type'] ) ? (string) $decoded['file_type'] : '';
976 $is_block = ( 'wp_block' === $file_type );
977 $content_str = is_string( $decoded['content'] );
978 $wireframe = ! empty( $this->context['wirefram_import'] );
979 $ai_import = 'ai_import' === ( $this->context['import_type'] ?? '' );
980
981 /* Precedence for the AI payload: a canonical payload already on disk, then the
982 * batched answer the browser handed down (converted here, no cloud call), then -
983 * only for an AI import with neither - a per-template generation. */
984 $payload = class_exists( 'Wdkit_Ai_Content' ) ? $this->local_ai_payload( $template_id, $decoded ) : null;
985
986 if ( null === $payload && $ai_import && class_exists( 'Wdkit_Ai_Content' ) ) {
987 $payload = $this->generate_payload( $template, $decoded );
988 }
989
990 $applied = 0;
991 $swapped = 0;
992 $globals = 0;
993 $changed = false;
994
995 if ( $is_block ) {
996 $blocks = parse_blocks( (string) $decoded['content'] );
997
998 if ( ! is_array( $blocks ) || empty( $blocks ) ) {
999 return $item;
1000 }
1001
1002 if ( null !== $payload ) {
1003 $applied = (int) Wdkit_Ai_Content::merge_gutenberg_blocks( $blocks, $payload );
1004 }
1005
1006 /* Stock + team image substitution. The engine has no notion of the visitor's
1007 * picked images, so a slot that is not swapped here keeps the template photo. */
1008 if ( ! $wireframe ) {
1009 $this->heartbeat( $template );
1010 $map = $this->image_map( $blocks, 'gutenberg' );
1011 $this->heartbeat( $template );
1012
1013 if ( ! empty( $map ) ) {
1014 $this->replace_block_image_urls( $blocks, $map );
1015 $swapped = count( $map );
1016 }
1017 }
1018
1019 /* `var(--tpgb-C*)` / `var(--tpgb-T*)` -> the ids this site assigned. */
1020 if ( class_exists( 'Wdkit_Import_Globals' ) ) {
1021 $moved = Wdkit_Import_Globals::rewrite_kit_references(
1022 $blocks,
1023 isset( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(),
1024 $this->session_id
1025 );
1026 $globals = is_array( $moved ) ? count( $moved ) : 0;
1027 }
1028
1029 $serialized = serialize_blocks( $blocks );
1030
1031 if ( is_string( $serialized ) && '' !== $serialized ) {
1032 $decoded['content'] = $serialized;
1033 $changed = true;
1034 }
1035 } else {
1036 $tree = $content_str ? json_decode( $decoded['content'], true ) : $decoded['content'];
1037
1038 if ( ! is_array( $tree ) || empty( $tree ) ) {
1039 return $item;
1040 }
1041
1042 if ( null !== $payload ) {
1043 /* Repeaters the batched answer under-filled — see top_up_repeaters(). This is
1044 * the path every real import takes, so the fix has to live here too. */
1045 $payload = $this->top_up_repeaters( $template, $decoded, $payload, $tree );
1046 $applied = (int) Wdkit_Ai_Content::merge_elementor( $tree, $payload );
1047 }
1048
1049 if ( ! $wireframe ) {
1050 $this->heartbeat( $template );
1051 $map = $this->image_map( $tree, 'elementor' );
1052 $this->heartbeat( $template );
1053
1054 if ( ! empty( $map ) ) {
1055 $encoded = wp_json_encode( $tree );
1056 $encoded = Wdkit_Import_Media::replace_urls_in_json( $encoded, $map );
1057 $rebuilt = json_decode( $encoded, true );
1058
1059 if ( is_array( $rebuilt ) && ! empty( $rebuilt ) ) {
1060 $tree = $rebuilt;
1061 $swapped = count( $map );
1062 }
1063 }
1064 }
1065
1066 /* `globals/colors?id=…` -> the ids this site will actually store the kit's globals
1067 * under. The Gutenberg branch above has its own equivalent
1068 * (rewrite_kit_references); this is the Elementor half, and it belongs HERE
1069 * rather than only in store().
1070 *
1071 * store() is the fallback path — it runs only when the kit bundle could not be
1072 * primed. Every normal import comes through import_via_bundle(), which hands the
1073 * content straight to the engine, so a remap applied only in store() never ran on
1074 * a real import: the globals were written under fresh ids while every page went on
1075 * referencing the old ones. Observed exactly that — a kit whose Primary collided
1076 * with an existing site colour rendered every button in the SITE's colour. */
1077 if ( class_exists( 'Wdkit_Import_Globals' ) ) {
1078 $id_map = Wdkit_Import_Globals::elementor_id_map(
1079 isset( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(),
1080 $this->session_id
1081 );
1082
1083 if ( ! empty( $id_map['color'] ) || ! empty( $id_map['typo'] ) ) {
1084 /* Through the JSON string, the way the image map above does it: the
1085 * reference is a URL inside a `__globals__` value, so an anchored string
1086 * swap touches exactly those and nothing else. */
1087 $encoded = wp_json_encode( $tree );
1088 $rewritten = Wdkit_Import_Globals::rewrite_elementor_global_ids( $encoded, $id_map );
1089
1090 if ( is_string( $rewritten ) && $rewritten !== $encoded ) {
1091 $rebuilt = json_decode( $rewritten, true );
1092
1093 if ( is_array( $rebuilt ) && ! empty( $rebuilt ) ) {
1094 $tree = $rebuilt;
1095 $globals = count( $id_map['color'] ) + count( $id_map['typo'] );
1096 }
1097 }
1098 }
1099 }
1100
1101 $changed = ( $applied > 0 ) || ( $swapped > 0 ) || ( $globals > 0 );
1102
1103 if ( $changed ) {
1104 /* Put `content` back the SHAPE the bundle handed it over in.
1105 * wdkit_bundle_insert_item() does `wp_json_encode( $post_content->content )`
1106 * on this value - so handing back a JSON string when the cloud shipped an
1107 * object double-encodes it and the Elementor save stores zero elements. */
1108 if ( $content_str ) {
1109 $re = wp_json_encode( $tree );
1110
1111 if ( is_string( $re ) && '' !== $re ) {
1112 $decoded['content'] = $re;
1113 }
1114 } else {
1115 $decoded['content'] = $tree;
1116 }
1117 }
1118 }
1119
1120 if ( $changed || $globals > 0 ) {
1121 $item['content'] = is_string( $item['content'] ) ? wp_json_encode( $decoded ) : $decoded;
1122 }
1123
1124 /* The stock slots this page localised, so import() can union them into the
1125 * background media sweep - the same list transform_bundle_item() carries in
1126 * `image_urls`. Without it a swapped-in image that was still remote at map time is
1127 * never scheduled and stays pointed at Pexels. */
1128 $still_remote = ( isset( $map ) && is_array( $map ) )
1129 ? array_values(
1130 array_filter(
1131 $map,
1132 static function ( $url ) {
1133 return is_string( $url ) && false === strpos( $url, '/wp-content/uploads/' );
1134 }
1135 )
1136 )
1137 : array();
1138
1139 $item['__ai_applied'] = $applied;
1140 $item['__images_swapped'] = $swapped;
1141 $item['__stock_remote'] = $still_remote;
1142
1143 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1144 Wdkit_Import_Log::add(
1145 'personalize',
1146 array(
1147 'template' => $template_id,
1148 'ai' => $applied,
1149 'images' => $swapped,
1150 'globals' => $globals,
1151 )
1152 );
1153 }
1154
1155 return $item;
1156 }
1157
1158 /**
1159 * The kit `global_data` the bundle carried for one template.
1160 *
1161 * @param string $template_id Template id.
1162 * @return array
1163 */
1164 private function bundle_global_data( $template_id ) {
1165 $raw = isset( self::$bundle[ $template_id ]['content'] ) ? self::$bundle[ $template_id ]['content'] : '';
1166
1167 $decoded = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : array() );
1168
1169 return ( is_array( $decoded ) && ! empty( $decoded['global_data'] ) && is_array( $decoded['global_data'] ) )
1170 ? $decoded['global_data']
1171 : array();
1172 }
1173
1174 /**
1175 * Whether to warm the media library eagerly, inside the import request.
1176 *
1177 * ── Measured, on a 15-template Gutenberg kit ────────────────────────────────
1178 *
1179 * The eager warm-up was 59.4s of a 71.3s import - 83% of the whole thing - against
1180 * 1.3s for the kit fetch and 10-20ms per page to store. It downloads concurrently, so
1181 * that time is not the downloads: it is creating 187 attachments in the request the
1182 * user is waiting on (file write, wp_insert_attachment, four meta rows each), which is
1183 * the one cost concurrency cannot help with.
1184 *
1185 * It also meant the deferral it now sits in front of never did anything. Only 2 of 15
1186 * pages had any deferred URLs left to schedule, because the warm-up had already
1187 * localised the other thirteen pages' images the expensive way.
1188 *
1189 * So the default is the same bargain the browser importer strikes: nothing is
1190 * downloaded on the critical path, the content keeps its source URLs, and
1191 * wdkit_async_sideload_page_images sweeps them up afterwards - home page first, and
1192 * drained by the success screen rather than waiting on a cron tick. SVGs are the
1193 * exception and are still fetched synchronously, inside wdkit_media_import(), because
1194 * the sweep runs with no user and so cannot upload them.
1195 *
1196 * The filter is the escape hatch for anyone who would rather pay the 59s and have every
1197 * image on disk before the import reports done - a migration script, say, or a host
1198 * with no working cron at all.
1199 *
1200 * @since 2.7.2
1201 *
1202 * @return bool
1203 */
1204 private function warm_media_eagerly() {
1205 /**
1206 * Filter whether the PHP runner downloads a page's media inside the import request.
1207 *
1208 * @since 2.7.2
1209 *
1210 * @param bool $eager Default false - defer to the background sweep.
1211 * @param array $context Import context.
1212 */
1213 return (bool) apply_filters( 'wdkit_import_eager_media', false, $this->context );
1214 }
1215
1216 /**
1217 * Make sure the media importer's static bookkeeping is loadable.
1218 *
1219 * wdkit_media_import() requires this file itself, but the deferred list has to be
1220 * cleared BEFORE that runs, so it cannot be relied on to have loaded it yet.
1221 *
1222 * @since 2.7.2
1223 *
1224 * @return void
1225 */
1226 private static function require_import_images() {
1227 if ( class_exists( 'Wdkit_Import_Images' ) || ! defined( 'WDKIT_INCLUDES' ) ) {
1228 return;
1229 }
1230
1231 $path = WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
1232
1233 if ( file_exists( $path ) ) {
1234 require_once $path;
1235 }
1236 }
1237
1238 /**
1239 * This template's payload out of the primed bundle, if it is in there.
1240 *
1241 * @param string $template_id Cloud template id.
1242 * @return array|null Decoded payload, or null to fall back to the per-template fetch.
1243 */
1244 private function bundle_content( $template_id ) {
1245 if ( empty( self::$bundle[ (string) $template_id ] ) ) {
1246 return null;
1247 }
1248
1249 $raw = self::$bundle[ (string) $template_id ]['content'];
1250
1251 $decoded = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : null );
1252
1253 return is_array( $decoded ) ? $decoded : null;
1254 }
1255
1256 /**
1257 * The builder this import targets.
1258 *
1259 * @return string 'elementor'|'gutenberg'
1260 */
1261 private function builder() {
1262 return ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] )
1263 ? 'gutenberg'
1264 : 'elementor';
1265 }
1266
1267 /**
1268 * Fetch, transform and store one template.
1269 *
1270 * `$overrides` is how a post import supplies the four things blog posts need on top of
1271 * the template — title, category ids, tag ids, featured image — without a second
1272 * importer. It is a closed set: anything not listed is ignored, so a caller (or a
1273 * remote payload that reached one) cannot set post_author, post_status or meta through
1274 * it. Omitting it leaves the behaviour byte-for-byte as it was.
1275 *
1276 * @param array $template Template record {id, title, type, wp_post_type}.
1277 * @param array $overrides Optional {title, categories:int[], tags:int[], featured_image}.
1278 * @return array{template_id:string,post_id:int,title:string,url:string,ai_applied:int,images_replaced:int}
1279 * @throws Wdkit_Import_Exception On fetch, parse or insert failure.
1280 */
1281 public function import( $template, $overrides = array() ) {
1282 /* Timed in three parts because they fail and slow down for different reasons: the
1283 * fetch is network to the cloud, the transform is where media sideloading happens
1284 * (usually the bulk of it), and the store is local database work. A single total
1285 * tells you an import was slow; this tells you which of the three to look at. */
1286 $t_start = microtime( true );
1287
1288 /* Start this template with an empty deferred list, for the same reason
1289 * wdkit_bundle_insert_item() does: Wdkit_Import_Images::$deferred_urls is static and
1290 * request-wide, so anything left in it from the previous template would be handed to
1291 * this one and scheduled against the wrong post. */
1292 self::require_import_images();
1293
1294 if ( class_exists( 'Wdkit_Import_Images' ) ) {
1295 Wdkit_Import_Images::get_and_clear_deferred_urls();
1296 }
1297
1298 /* The browser flow's engine, given this one template. Returns null only when the
1299 * kit bundle could not be primed, in which case the hand-rolled fetch/transform/
1300 * store below still runs - the same path this class has always had, kept as the
1301 * fallback rather than deleted so an older backend with no bundle route still
1302 * imports. */
1303 $via_bundle = $this->import_via_bundle( $template, $overrides );
1304
1305 if ( null !== $via_bundle ) {
1306 $t_end = microtime( true );
1307
1308 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1309 Wdkit_Import_Log::add(
1310 'template',
1311 array(
1312 'id' => isset( $template['id'] ) ? (string) $template['id'] : '',
1313 'title' => isset( $template['title'] ) ? explode( '|', (string) $template['title'] )[0] : '',
1314 'ms' => (int) round( ( $t_end - $t_start ) * 1000 ),
1315 'engine' => 'bundle',
1316 )
1317 );
1318 }
1319
1320 /* import_via_bundle() already carried this page's deferred URLs over from the
1321 * bundle insert's own result (see the `image_urls` note on its return array).
1322 * Falling back to the static list here — as this used to do unconditionally —
1323 * is wrong for the bundle path specifically: wdkit_bundle_insert_item() (inside
1324 * wdkit_import_site_bundle_data(), called by import_via_bundle() above) already
1325 * drained Wdkit_Import_Images::$deferred_urls into its own result before this
1326 * point, so reading it again here always finds it empty and silently drops
1327 * every deferred image — which is exactly what sent every one of them through
1328 * the synchronous finalize sweep instead of the background pass. Only reach for
1329 * the static list when the bundle path genuinely did not supply one. */
1330 if ( empty( $via_bundle['image_urls'] ) ) {
1331 $via_bundle['image_urls'] = ( class_exists( 'Wdkit_Import_Images' ) )
1332 ? array_values( array_unique( array_filter( (array) Wdkit_Import_Images::get_and_clear_deferred_urls(), 'is_string' ) ) )
1333 : array();
1334 }
1335
1336 return $via_bundle;
1337 }
1338
1339 $decoded = $this->fetch( $template );
1340 $t_fetch = microtime( true );
1341 $this->heartbeat( $template );
1342
1343 $prepared = $this->transform( $template, $decoded );
1344 $t_trans = microtime( true );
1345 $this->heartbeat( $template );
1346
1347 $page = $this->store( $template, $prepared, $decoded, $overrides );
1348 $t_end = microtime( true );
1349
1350 /* What this page left pointing at the source CDN. The runner hands these to
1351 * wdkit_schedule_media_sync_for() once the stage's pages exist, which is the same
1352 * background sweep the browser importer schedules - so a deferred image is localised
1353 * and its attachment id written back, off the critical path. Without this the
1354 * deferral would be a leak rather than an optimisation: the URLs would stay remote
1355 * with nothing queued to ever fetch them. */
1356 $page['image_urls'] = ( class_exists( 'Wdkit_Import_Images' ) )
1357 ? array_values( array_unique( array_filter( (array) Wdkit_Import_Images::get_and_clear_deferred_urls(), 'is_string' ) ) )
1358 : array();
1359
1360 /* Images this page's own media walk could not localise — drained the same
1361 * read-and-clear way image_urls above is, so this page's result carries only its
1362 * own failures. Summed across every page's result at the run's own finish() the
1363 * same way failed_pages already is. See ClickUp 14ynqxywnae. */
1364 $page['failed_images'] = ( class_exists( 'Wdkit_Import_Images' ) )
1365 ? Wdkit_Import_Images::get_and_clear_failed_downloads()
1366 : array();
1367
1368 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1369 Wdkit_Import_Log::add(
1370 'template',
1371 array(
1372 'id' => isset( $template['id'] ) ? (string) $template['id'] : '',
1373 'title' => isset( $template['title'] ) ? explode( '|', (string) $template['title'] )[0] : '',
1374 'ms' => (int) round( ( $t_end - $t_start ) * 1000 ),
1375 'fetch_ms' => (int) round( ( $t_fetch - $t_start ) * 1000 ),
1376 'transform_ms' => (int) round( ( $t_trans - $t_fetch ) * 1000 ),
1377 'store_ms' => (int) round( ( $t_end - $t_trans ) * 1000 ),
1378 'images' => isset( $prepared['images_replaced'] ) ? (int) $prepared['images_replaced'] : 0,
1379 'bytes' => isset( $prepared['content'] ) ? strlen( (string) $prepared['content'] ) : 0,
1380 )
1381 );
1382 }
1383
1384 return $page;
1385 }
1386
1387 /**
1388 * Pull a template's payload from the cloud through the existing hook.
1389 *
1390 * @param array $template Template record.
1391 * @return array Decoded template payload.
1392 * @throws Wdkit_Import_Exception
1393 */
1394 private function fetch( $template ) {
1395 /* Primed by the runner before the first template - the whole kit in one response.
1396 * A hit costs nothing here at all; a miss falls through to the per-template route
1397 * below, which is the only behaviour this class used to have. */
1398 $from_bundle = $this->bundle_content( isset( $template['id'] ) ? $template['id'] : '' );
1399
1400 if ( null !== $from_bundle ) {
1401 return $from_bundle;
1402 }
1403
1404 $fetch = apply_filters(
1405 'wdkit_import_kit_content',
1406 array(),
1407 array(
1408 'template_id' => $template['id'],
1409 'editor' => $this->builder(),
1410 'website_kit' => isset( $this->context['kit_id'] ) ? $this->context['kit_id'] : '',
1411 'api_type' => 'import_template',
1412 'custom_meta' => false,
1413 )
1414 );
1415
1416 if ( empty( $fetch['success'] ) || empty( $fetch['response']['content'] ) ) {
1417 $message = ! empty( $fetch['message'] ) ? $fetch['message'] : __( 'Failed to fetch template from cloud.', 'wdesignkit' );
1418
1419 /* An expired or missing cloud login is not a transient failure - every retry gets
1420 * the same answer, and the fix is a human clicking Login. Treating it as retryable
1421 * meant a site whose token had gone stale sat on the progress screen re-requesting
1422 * the same template hundreds of times instead of showing the message that already
1423 * said exactly what to do. */
1424 if ( self::is_auth_failure( $fetch ) ) {
1425 throw new Wdkit_Import_Non_Retryable_Exception(
1426 $message,
1427 'cloud_not_authenticated',
1428 'fetch_' . $template['id']
1429 );
1430 }
1431
1432 throw new Wdkit_Import_Retryable_Exception(
1433 $message,
1434 'cloud_fetch_failed',
1435 'fetch_' . $template['id']
1436 );
1437 }
1438
1439 $decoded = json_decode( $fetch['response']['content'], true );
1440
1441 if ( ! is_array( $decoded ) ) {
1442 throw new Wdkit_Import_Non_Retryable_Exception(
1443 __( 'Cloud returned invalid JSON content.', 'wdesignkit' ),
1444 'invalid_template_json',
1445 'fetch_' . $template['id']
1446 );
1447 }
1448
1449 return $decoded;
1450 }
1451
1452 /**
1453 * Does this cloud response mean "you are not signed in"?
1454 *
1455 * The hook reports auth trouble in the message rather than with a code, so this matches
1456 * on the shape of that message. Deliberately conservative: anything it does not
1457 * recognise stays retryable, so a genuine blip is still retried.
1458 *
1459 * @param array $fetch Filter result.
1460 * @return bool
1461 */
1462 private static function is_auth_failure( $fetch ) {
1463 if ( ! empty( $fetch['code'] ) && in_array( (string) $fetch['code'], array( 'not_logged_in', 'unauthorized', 'invalid_token' ), true ) ) {
1464 return true;
1465 }
1466
1467 $message = isset( $fetch['message'] ) && is_string( $fetch['message'] ) ? strtolower( $fetch['message'] ) : '';
1468
1469 if ( '' === $message ) {
1470 return false;
1471 }
1472
1473 foreach ( array( 'not logged in', 'log in', 'login', 'unauthorized', 'unauthenticated', 'invalid token', 'token expired' ) as $needle ) {
1474 if ( false !== strpos( $message, $needle ) ) {
1475 return true;
1476 }
1477 }
1478
1479 return false;
1480 }
1481
1482 /**
1483 * Apply AI copy and image substitution, returning storable content.
1484 *
1485 * @param array $template Template record.
1486 * @param array $decoded Decoded template payload.
1487 * @return array{content:string,ai_applied:int,images_replaced:int}
1488 */
1489 private function transform( $template, $decoded ) {
1490 $this->assert_builder_matches( $template, $decoded );
1491
1492 $this->credits = array();
1493
1494 $payload = $this->local_ai_payload( (string) ( $template['id'] ?? '' ), $decoded );
1495
1496 /* No local payload for this template and this is an AI import: generate one now. A
1497 * caller that already handed over an ai_document keeps precedence — that is the
1498 * remote case, where generation happened on the caller's account. */
1499 if ( null === $payload && 'ai_import' === ( $this->context['import_type'] ?? '' ) ) {
1500 $payload = $this->generate_payload( $template, $decoded );
1501 }
1502
1503 /* Repeaters the batched answer under-filled — see top_up_repeaters(). */
1504 if ( null !== $payload && 'gutenberg' !== $this->builder() ) {
1505 $payload = $this->top_up_repeaters( $template, $decoded, $payload );
1506 }
1507
1508 return ( 'gutenberg' === $this->builder() )
1509 ? $this->transform_gutenberg( $decoded, $payload )
1510 : $this->transform_elementor( $decoded, $payload );
1511 }
1512
1513 /**
1514 * The AI payload for one template without a cloud call: a canonical payload already on
1515 * disk, or the browser's batched answer converted against this template's element tree.
1516 *
1517 * Returns null when neither has anything; the caller decides whether a per-template
1518 * generation is warranted. A conversion is cached so a later slice or a retry does not
1519 * repeat it.
1520 *
1521 * @param string $template_id Template id.
1522 * @param array $decoded Decoded template payload ({content, file_type, ...}).
1523 * @return array|null Canonical payload.
1524 */
1525 private function local_ai_payload( $template_id, $decoded ) {
1526 if ( '' === $template_id || ! class_exists( 'Wdkit_Ai_Content' ) ) {
1527 return null;
1528 }
1529
1530 $payload = Wdkit_Ai_Content::load_payload( $this->session_id, $template_id );
1531
1532 if ( is_array( $payload ) ) {
1533 return $payload;
1534 }
1535
1536 $document = ( isset( $this->context['ai_document'] ) && is_array( $this->context['ai_document'] ) )
1537 ? $this->context['ai_document']
1538 : array();
1539
1540 $raw_answer = Wdkit_Ai_Content::page_raw_answer( $document, $template_id );
1541
1542 if ( empty( $raw_answer ) || ! isset( $decoded['content'] ) ) {
1543 return null;
1544 }
1545
1546 $file_type = ( isset( $decoded['file_type'] ) && is_string( $decoded['file_type'] ) && '' !== $decoded['file_type'] )
1547 ? $decoded['file_type']
1548 : ( 'gutenberg' === $this->builder() ? 'wp_block' : 'elementor' );
1549
1550 $tree = ( 'wp_block' === $file_type )
1551 ? ( function_exists( 'parse_blocks' ) ? parse_blocks( (string) $decoded['content'] ) : array() )
1552 : ( is_string( $decoded['content'] ) ? json_decode( $decoded['content'], true ) : $decoded['content'] );
1553
1554 if ( ! is_array( $tree ) || empty( $tree ) ) {
1555 return null;
1556 }
1557
1558 $converted = Wdkit_Ai_Content::browser_answer_to_payload(
1559 $raw_answer,
1560 $tree,
1561 $file_type,
1562 ( isset( $this->context['site_info'] ) && is_array( $this->context['site_info'] ) ) ? $this->context['site_info'] : array()
1563 );
1564
1565 if ( empty( $converted['elements'] ) ) {
1566 return null;
1567 }
1568
1569 Wdkit_Ai_Content::save_payload( $this->session_id, $template_id, $converted );
1570
1571 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1572 Wdkit_Import_Log::add(
1573 'ai_convert',
1574 array(
1575 'template' => $template_id,
1576 'elements' => count( $converted['elements'] ),
1577 )
1578 );
1579 }
1580
1581 return $converted;
1582 }
1583
1584 /**
1585 * Generate AI copy for one template, server-side.
1586 *
1587 * This is the same round trip the browser makes, moved into the process doing the
1588 * import. The steps are the browser's, in the browser's order:
1589 *
1590 * 1. walk the template for `wdkitai_*` instructions (Wdkit_Ai_Content::extract_ai_object,
1591 * the PHP port of get_ai_object / get_gutenberg_obj)
1592 * 2. POST them to `ai/template_import` with the site's own token
1593 * (Wdkit_Import_temp_Ajax::wdkit_generate_ai_content_data, the extracted handler)
1594 * 3. normalise the answer into the canonical payload
1595 *
1596 * Because step 2 is the identical handler hitting the identical endpoint with the
1597 * identical token, credits are consumed and counted exactly as they are today. This is
1598 * not a second generator.
1599 *
1600 * A template with no instructions costs nothing — it returns before the request. Any
1601 * failure returns null, which the merge treats as "no AI", leaving the template's own
1602 * copy in place rather than failing the page.
1603 *
1604 * @param array $template Template record.
1605 * @param array $decoded Decoded template payload.
1606 * @return array|null Canonical payload, or null.
1607 */
1608 /**
1609 * Ask again, for just the repeaters the AI under-filled.
1610 *
1611 * The batched answer covers a whole page in one request, and on a request that large the
1612 * model does not reliably honour a count: asked for "a list of 5 process steps", it
1613 * returned one. merge_elementor() fills row N from value N, so rows 2-5 then kept the
1614 * template's copy and the imported About page read "Zion was founded with a bold vision to
1615 * transform the SaaS landscape" four times over, on a freight company's site. The same
1616 * thing left demo questions in the landing page FAQ.
1617 *
1618 * One focused follow-up per page, carrying ONLY the short elements, with the count made
1619 * explicit in each instruction. Asked about one widget at a time the model does return
1620 * the right number. The new values replace the short list outright rather than being
1621 * appended to it, so every row of the widget comes from one coherent answer rather than a
1622 * lone original followed by four afterthoughts.
1623 *
1624 * Best-effort: if the follow-up fails or is still short, the payload is returned as it
1625 * was, which is exactly what imported before. Logged as `ai_topup` either way.
1626 *
1627 * @since 2.7.2
1628 *
1629 * @param array $template Template stub.
1630 * @param array $decoded Decoded template payload.
1631 * @param array $payload Canonical AI payload.
1632 * @param array|null $tree Parsed element tree, when the caller already has it.
1633 * @return array Payload, topped up where the follow-up delivered.
1634 */
1635 private function top_up_repeaters( $template, $decoded, $payload, $tree = null ) {
1636 if ( ! is_array( $payload ) || empty( $payload['elements'] ) ) {
1637 return $payload;
1638 }
1639
1640 if ( 'ai_import' !== ( $this->context['import_type'] ?? '' ) || ! empty( $this->context['wirefram_import'] ) ) {
1641 return $payload;
1642 }
1643
1644 if ( null === $tree ) {
1645 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
1646 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
1647 }
1648
1649 if ( ! is_array( $tree ) || empty( $tree ) || ! class_exists( 'Wdkit_Ai_Content' ) ) {
1650 return $payload;
1651 }
1652
1653 $short = Wdkit_Ai_Content::repeater_shortfalls( $tree, $payload );
1654
1655 if ( empty( $short ) ) {
1656 return $payload;
1657 }
1658
1659 $template_id = isset( $template['id'] ) ? (string) $template['id'] : '';
1660
1661 /* Only the short elements, each instruction told the count explicitly. */
1662 $section_map = Wdkit_Ai_Content::extract_ai_object( $tree )['sectionMap'];
1663 $focused = array();
1664
1665 foreach ( (array) $section_map as $section => $section_value ) {
1666 foreach ( ( isset( $section_value['elements'] ) ? (array) $section_value['elements'] : array() ) as $element ) {
1667 $element_id = isset( $element['id'] ) ? (string) $element['id'] : '';
1668
1669 if ( '' === $element_id || ! isset( $short[ $element_id ] ) ) {
1670 continue;
1671 }
1672
1673 $count = (int) $short[ $element_id ]['need'];
1674
1675 foreach ( $element as $key => $value ) {
1676 if ( 'id' !== $key && is_string( $value ) && 0 === strpos( $key, 'wdkitai_' ) ) {
1677 $element[ $key ] = rtrim( $value ) . ' Return exactly ' . $count . ' separate entries, one per item, as a list of ' . $count . ' values.';
1678 }
1679 }
1680
1681 $focused[ $section ]['elements'][] = $element;
1682 }
1683 }
1684
1685 if ( empty( $focused ) ) {
1686 return $payload;
1687 }
1688
1689 $instance = class_exists( 'Wdkit_Import_temp_Ajax' ) ? Wdkit_Import_temp_Ajax::get_instance() : null;
1690 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
1691
1692 /* An empty JWT alone is no longer a reason to skip this follow-up - see the note by
1693 * the main per-template gate below. */
1694 $can_authenticate = function_exists( 'wdkit_kit_import_can_authenticate' ) && wdkit_kit_import_can_authenticate( $token );
1695
1696 if ( null === $instance || ! method_exists( $instance, 'wdkit_generate_ai_content_data' ) || ! $can_authenticate ) {
1697 return $payload;
1698 }
1699
1700 $settings = ( isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ) ? $decoded['settings'] : array();
1701 $page_type = ( ! empty( $settings['wdkitai_page_type'] ) && is_string( $settings['wdkitai_page_type'] ) ) ? $settings['wdkitai_page_type'] : 'custom';
1702 $info = ( ! empty( $this->context['site_info'] ) && is_array( $this->context['site_info'] ) ) ? $this->context['site_info'] : array();
1703
1704 $this->heartbeat( $template );
1705
1706 $started = microtime( true );
1707 $response = $instance->wdkit_generate_ai_content_data(
1708 array(
1709 'text_array' => array( $page_type => $focused ),
1710 'type' => isset( $this->context['site_type'] ) ? $this->context['site_type'] : '',
1711 'title' => isset( $info['site_name'] ) ? $info['site_name'] : '',
1712 'language' => isset( $this->context['site_lang'] ) ? $this->context['site_lang'] : 'english',
1713 'agency' => isset( $this->context['site_agency'] ) ? $this->context['site_agency'] : '',
1714 'description' => isset( $this->context['site_description'] ) ? $this->context['site_description'] : '',
1715 'builder' => 'elementor',
1716 'token' => $token,
1717 )
1718 );
1719
1720 $this->heartbeat( $template );
1721
1722 $filled = 0;
1723
1724 if ( is_array( $response ) && empty( $response['__ai_failed'] ) ) {
1725 $raw = null;
1726
1727 foreach ( array( 'response', 'data' ) as $key ) {
1728 if ( ! empty( $response[ $key ] ) ) {
1729 $raw = $response[ $key ];
1730 break;
1731 }
1732 }
1733
1734 $more = ( null !== $raw ) ? Wdkit_Ai_Content::browser_answer_to_payload( $raw, $tree, 'elementor', $info ) : array();
1735
1736 /* Index the follow-up the same way the merge reads it: id => key => values. */
1737 $by_id = array();
1738
1739 foreach ( ( isset( $more['elements'] ) ? (array) $more['elements'] : array() ) as $entry ) {
1740 foreach ( ( isset( $entry['data'] ) ? (array) $entry['data'] : array() ) as $pair ) {
1741 foreach ( (array) $pair as $key => $values ) {
1742 $by_id[ (string) $entry['id'] ][ $key ] = is_array( $values ) ? $values : array();
1743 }
1744 }
1745 }
1746
1747 foreach ( $short as $element_id => $gap ) {
1748 foreach ( $gap['keys'] as $key => $counts ) {
1749 $values = isset( $by_id[ $element_id ][ $key ] ) ? $by_id[ $element_id ][ $key ] : array();
1750
1751 /* Only an answer that actually fills the repeater replaces the original;
1752 * a follow-up that is still short would just trade one partial list for
1753 * another. */
1754 if ( count( $values ) >= (int) $counts['need'] ) {
1755 $payload = Wdkit_Ai_Content::replace_payload_values( $payload, $element_id, $key, $values );
1756 ++$filled;
1757 }
1758 }
1759 }
1760
1761 /* Billed like any other AI call, so it has to be reported like one. */
1762 foreach ( array( 'used_credits', 'real_credit' ) as $key ) {
1763 if ( isset( $response[ $key ] ) && is_numeric( $response[ $key ] ) ) {
1764 $this->credits[ $key ] = ( isset( $this->credits[ $key ] ) ? $this->credits[ $key ] : 0 ) + $response[ $key ];
1765 }
1766 }
1767
1768 if ( $filled > 0 && '' !== $template_id ) {
1769 /* So a retry of this template reuses the completed answer. */
1770 Wdkit_Ai_Content::save_payload( $this->session_id, $template_id, $payload );
1771 }
1772 }
1773
1774 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1775 $wanted = 0;
1776
1777 foreach ( $short as $gap ) {
1778 $wanted += count( $gap['keys'] );
1779 }
1780
1781 Wdkit_Import_Log::add(
1782 'ai_topup',
1783 array(
1784 'template' => $template_id,
1785 'short' => $wanted,
1786 'filled' => $filled,
1787 'ms' => (int) round( ( microtime( true ) - $started ) * 1000 ),
1788 'ok' => ( is_array( $response ) && empty( $response['__ai_failed'] ) ) ? 1 : 0,
1789 )
1790 );
1791 }
1792
1793 return $payload;
1794 }
1795
1796 /**
1797 * Record why a per-template AI generation produced nothing, and return null.
1798 *
1799 * Every bail-out in generate_payload() used to `return null` with no trace, so the only
1800 * evidence left was `ai: 0` on the personalize log line — which reads identically whether
1801 * the template asked for no AI copy, the token was missing, or the cloud call failed. A
1802 * front page that silently kept its template copy could not be told from one that had
1803 * nothing to personalise, and that is exactly the case that went unexplained.
1804 *
1805 * @since 2.7.2
1806 *
1807 * @param string $template_id Template id.
1808 * @param string $reason Short machine-readable cause.
1809 * @param array $extra Optional detail.
1810 * @return null Always — callers `return $this->ai_gave_up( … )`.
1811 */
1812 private function ai_gave_up( $template_id, $reason, $extra = array() ) {
1813 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1814 Wdkit_Import_Log::add(
1815 'ai_skip',
1816 array_merge(
1817 array(
1818 'template' => (string) $template_id,
1819 'reason' => $reason,
1820 ),
1821 $extra
1822 )
1823 );
1824 }
1825
1826 return null;
1827 }
1828
1829 private function generate_payload( $template, $decoded ) {
1830 $template_id = isset( $template['id'] ) ? (string) $template['id'] : '';
1831
1832 if ( ! class_exists( 'Wdkit_Import_temp_Ajax' ) ) {
1833 return $this->ai_gave_up( $template_id, 'ajax_class_missing' );
1834 }
1835
1836 $instance = Wdkit_Import_temp_Ajax::get_instance();
1837
1838 if ( ! method_exists( $instance, 'wdkit_generate_ai_content_data' ) ) {
1839 return $this->ai_gave_up( $template_id, 'handler_missing' );
1840 }
1841
1842 $builder = $this->builder();
1843 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
1844
1845 /* Elementor carries an element tree; Gutenberg carries block markup, which
1846 * extract_ai_object() reads once parsed. */
1847 if ( 'gutenberg' === $builder ) {
1848 $tree = function_exists( 'parse_blocks' ) ? parse_blocks( is_string( $content ) ? $content : '' ) : array();
1849 } else {
1850 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
1851 }
1852
1853 if ( ! is_array( $tree ) || empty( $tree ) ) {
1854 return $this->ai_gave_up( $template_id, 'unparsable_content' );
1855 }
1856
1857 /* Two shapes, two walkers. Using the Elementor walker on a block tree finds
1858 * nothing and silently downgrades an ai_import to template copy. */
1859 if ( 'gutenberg' === $builder ) {
1860 $pair = Wdkit_Ai_Content::extract_ai_pair_gutenberg( $tree );
1861 $section_map = $pair['request'];
1862 } else {
1863 $section_map = Wdkit_Ai_Content::extract_ai_object( $tree )['sectionMap'];
1864 }
1865
1866 if ( ! self::has_ai_elements( $section_map ) ) {
1867 /* Nothing in this template asks for AI copy. Not an error, and not a request —
1868 * logged all the same so it can be told apart from a generation that failed. */
1869 return $this->ai_gave_up( $template_id, 'no_ai_instructions' );
1870 }
1871
1872 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
1873
1874 /* An empty JWT alone is no longer "not logged in" - a sandbox site has no account
1875 * session but does hold a poll token the cloud can authenticate the same call on
1876 * (carried into the request by wdkit_generate_ai_content_data() itself). Only bail
1877 * here when neither credential exists. */
1878 if ( ! function_exists( 'wdkit_kit_import_can_authenticate' ) || ! wdkit_kit_import_can_authenticate( $token ) ) {
1879 return $this->ai_gave_up( $template_id, 'no_cloud_token' );
1880 }
1881
1882 $info = ( ! empty( $this->context['site_info'] ) && is_array( $this->context['site_info'] ) ) ? $this->context['site_info'] : array();
1883
1884 /* The browser sends `text_array` as `{ <page_type>: <sectionMap> }` - the section
1885 * map wrapped in the page's type (import_loader.js: parent = { [page_type]:
1886 * text_obj.sectionMap }). Sending the bare `{ sectionMap, detailsection }` object
1887 * instead is what made every Elementor per-template fallback come back with copy the
1888 * merge could not place - a page that batch-failed then silently kept its template
1889 * text. `custom` is the browser's own default when the template does not declare one. */
1890 $page_type = 'custom';
1891
1892 $settings = ( isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ) ? $decoded['settings'] : array();
1893
1894 if ( ! empty( $settings['wdkitai_page_type'] ) && is_string( $settings['wdkitai_page_type'] ) ) {
1895 $page_type = $settings['wdkitai_page_type'];
1896 }
1897
1898 /* prime_ai_batch() already asked the cloud for every template's copy in one request,
1899 * before this loop started. A hit here means skipping this template's own network
1900 * round trip entirely; a miss (batch never ran, failed outright, or answered without
1901 * this one page) falls straight through to the original per-template call below, so
1902 * nothing about the AI-import path regresses when the cache has nothing to offer. */
1903 $batched = self::batched_answer( $template_id, (string) $this->session_id );
1904
1905 if ( is_array( $batched ) ) {
1906 $response = ! empty( $batched['success'] )
1907 ? $batched
1908 : array(
1909 'success' => false,
1910 'message' => isset( $batched['message'] ) ? (string) $batched['message'] : '',
1911 'description' => esc_html__( 'Ai data not found', 'wdesignkit' ),
1912 '__ai_failed' => true,
1913 );
1914 } else {
1915 $response = $instance->wdkit_generate_ai_content_data(
1916 array(
1917 'text_array' => array( $page_type => $section_map ),
1918 'type' => isset( $this->context['site_type'] ) ? $this->context['site_type'] : '',
1919 'title' => isset( $info['site_name'] ) ? $info['site_name'] : '',
1920 'language' => isset( $this->context['site_lang'] ) ? $this->context['site_lang'] : 'english',
1921 'agency' => isset( $this->context['site_agency'] ) ? $this->context['site_agency'] : '',
1922 'description' => isset( $this->context['site_description'] ) ? $this->context['site_description'] : '',
1923 'builder' => $builder,
1924 'token' => $token,
1925 )
1926 );
1927 }
1928
1929 if ( ! is_array( $response ) || ! empty( $response['__ai_failed'] ) ) {
1930 return $this->ai_gave_up(
1931 $template_id,
1932 'cloud_call_failed',
1933 array( 'message' => is_array( $response ) && isset( $response['message'] ) ? (string) $response['message'] : '' )
1934 );
1935 }
1936
1937 /* The endpoint answers under `response` (a JSON string) or `data`, matching what
1938 * the browser reads off the same call. */
1939 $raw = null;
1940
1941 foreach ( array( 'response', 'data' ) as $key ) {
1942 if ( isset( $response[ $key ] ) && ! empty( $response[ $key ] ) ) {
1943 $raw = $response[ $key ];
1944 break;
1945 }
1946 }
1947
1948 if ( null === $raw ) {
1949 return $this->ai_gave_up( $template_id, 'empty_cloud_answer' );
1950 }
1951
1952 /* The cloud answers section-keyed and instruction-keyed - the same shape the batched
1953 * flow returns - so it goes through the same converter: it reads the `_replace`
1954 * targets off this template's own tree and rewrites the answer onto them. Using
1955 * normalize_payload() here (which only accepts an already-resolved payload) is what
1956 * left the Elementor fallback applying nothing. */
1957 $file_type = isset( $decoded['file_type'] ) && is_string( $decoded['file_type'] ) && '' !== $decoded['file_type']
1958 ? $decoded['file_type']
1959 : ( 'gutenberg' === $builder ? 'wp_block' : 'elementor' );
1960
1961 $payload = Wdkit_Ai_Content::browser_answer_to_payload( $raw, $tree, $file_type, $info );
1962
1963 if ( empty( $payload['elements'] ) ) {
1964 /* A remote caller may hand back an already-canonical answer; still honour it. */
1965 $payload = Wdkit_Ai_Content::normalize_payload( $raw, $builder );
1966 }
1967
1968 if ( empty( $payload['elements'] ) ) {
1969 /* The cloud answered, but nothing in the answer could be placed against this
1970 * template's tree. Distinct from an outright failure and worth its own reason:
1971 * it points at the converter or the page_type, not at the network. */
1972 return $this->ai_gave_up( $template_id, 'answer_placed_nothing' );
1973 }
1974
1975 /* Credits the cloud reports for this template. The wizard has always posted these
1976 * to /after/import once the run finishes, so they have to survive the move
1977 * server-side or an AI import would stop being billed. Carried on the payload and
1978 * lifted onto the page result in transform(). */
1979 foreach ( array( 'used_credits', 'real_credit' ) as $key ) {
1980 if ( isset( $response[ $key ] ) && is_numeric( $response[ $key ] ) ) {
1981 $this->credits[ $key ] = 0 + $response[ $key ];
1982 }
1983 }
1984
1985 /* Persisted so a retry of a LATER template does not pay for this one again. */
1986 Wdkit_Ai_Content::save_payload( $this->session_id, $template['id'], $payload );
1987
1988 return $payload;
1989 }
1990
1991 /**
1992 * Refuse a payload whose content is not the builder we are importing as.
1993 *
1994 * Found by running against real WordPress, and worth spelling out because the failure
1995 * mode was silent. Asking the cloud for a Gutenberg kit with `editor=elementor` returns
1996 * the Gutenberg content regardless — the `editor` argument does not convert anything.
1997 * Without this guard the runner stored block markup (`<!-- wp:… -->`) into
1998 * `_elementor_data` and set `_elementor_edit_mode=builder`, which Elementor then parsed
1999 * as **zero elements**: a published, blank page, with the real content stranded in a
2000 * meta key nothing reads.
2001 *
2002 * The existing importer never produces that. `import_page_section` enters its Elementor
2003 * branch on `editor`, but then gates the write on `'elementor' === $file_type`
2004 * (class-api.php:4590) and gates the Gutenberg write on `'wp_block' === $file_type`
2005 * (:4457) — so on a mismatch it simply writes nothing. This reproduces that outcome and
2006 * makes it legible: the template is refused with a code, rather than silently skipped.
2007 *
2008 * `file_type` is authoritative. When a payload does not carry one, the content is
2009 * sniffed instead, so older payloads still import.
2010 *
2011 * @param array $template Template record.
2012 * @param array $decoded Decoded template payload.
2013 * @return void
2014 * @throws Wdkit_Import_Non_Retryable_Exception On mismatch.
2015 */
2016 private function assert_builder_matches( $template, $decoded ) {
2017 $builder = $this->builder();
2018 $expected = ( 'gutenberg' === $builder ) ? 'wp_block' : 'elementor';
2019
2020 $file_type = isset( $decoded['file_type'] ) && is_string( $decoded['file_type'] )
2021 ? strtolower( trim( $decoded['file_type'] ) )
2022 : '';
2023
2024 if ( '' === $file_type ) {
2025 /* No declared type: sniff. Serialised blocks always open with a block comment,
2026 * an Elementor tree never does. */
2027 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
2028 $content = is_string( $content ) ? ltrim( $content ) : '';
2029
2030 if ( '' === $content ) {
2031 return;
2032 }
2033
2034 $file_type = ( 0 === strpos( $content, '<!-- wp:' ) ) ? 'wp_block' : 'elementor';
2035 }
2036
2037 if ( $file_type === $expected ) {
2038 return;
2039 }
2040
2041 /* The client never said which builder this kit uses, so builder() is only reporting
2042 * the context default. The content is the ground truth - adopt it rather than refuse.
2043 *
2044 * This is the case that produced "Template content is gutenberg but the import is
2045 * running as elementor" on a perfectly ordinary Gutenberg kit: wkitGetBuilder()
2046 * returns '' when the kit's builder id is missing from the loaded builder list, and
2047 * an unknown builder was being normalised into a confident "elementor". Refusing was
2048 * right when the caller had genuinely asked for Elementor; here nobody asked. */
2049 if ( empty( $this->context['builder_explicit'] ) ) {
2050 $this->context['builder'] = ( 'wp_block' === $file_type ) ? 'gutenberg' : 'elementor';
2051
2052 /* Persist it. The browser branches on the builder in a dozen places - whether to
2053 * run the CSS resync pass, whether to rewrite globals, whether to show the
2054 * success screen - and it got '' for the same reason the server did. Resolving it
2055 * here and only here would fix the import and still leave the site unstyled,
2056 * because start_tpgb_resync() would skip. The adapter hands this back so the
2057 * browser can correct itself. */
2058 if ( '' !== $this->session_id ) {
2059 Wdkit_Import_Session::update( $this->session_id, array( 'builder' => $this->context['builder'] ) );
2060 }
2061
2062 if ( class_exists( 'Wdkit_Import_Log' ) ) {
2063 Wdkit_Import_Log::add(
2064 'builder_resolved',
2065 array(
2066 'template' => isset( $template['id'] ) ? (string) $template['id'] : '',
2067 'from' => $builder,
2068 'to' => $this->context['builder'],
2069 'reason' => 'client sent no builder; used template file_type',
2070 )
2071 );
2072 }
2073
2074 return;
2075 }
2076
2077 /* Skippable, not non-retryable: this ONE template's catalogue record is
2078 * mismatched (a Gutenberg entry filed under an Elementor kit variant, seen in
2079 * practice on kits whose "Elementor" and "Gutenberg" listings share template ids
2080 * that were not kept in sync) - it says nothing about the other N-1 templates in
2081 * the kit, which are fine. Non_Retryable told the RUNNER the whole import could
2082 * not succeed and to stop; the caller sees that as a stalled step needing Retry,
2083 * on a kit the existing browser importer has always completed without a word,
2084 * because wdkit_bundle_insert_item() (class-api.php) never refused in the first
2085 * place - it silently resolves $editor from each template's own $file_type and
2086 * writes nothing when even that fails (class-api.php:8911 / :9071). Matching that:
2087 * the run continues, and only this one page is missing. */
2088 throw new Wdkit_Import_Skippable_Exception(
2089 sprintf(
2090 /* translators: 1: builder being imported, 2: content type the cloud returned. */
2091 __( 'Template content is %2$s but the import is running as %1$s. Skipped, because it would produce an empty page.', 'wdesignkit' ),
2092 $builder,
2093 ( 'wp_block' === $file_type ) ? 'gutenberg' : $file_type
2094 ),
2095 'builder_content_mismatch',
2096 'transform_' . $template['id']
2097 );
2098 }
2099
2100 /**
2101 * Elementor: decode → merge → substitute images → re-encode.
2102 *
2103 * @param array $decoded Decoded template payload.
2104 * @param array|null $payload Canonical AI payload.
2105 * @return array
2106 */
2107 private function transform_elementor( $decoded, $payload ) {
2108 $content = isset( $decoded['content'] ) ? $decoded['content'] : '';
2109 $tree = is_string( $content ) ? json_decode( $content, true ) : $content;
2110
2111 $ai_applied = 0;
2112
2113 if ( ! is_array( $tree ) ) {
2114 /** nothing parseable — store what the cloud sent, untouched */
2115 return array(
2116 'content' => is_string( $content ) ? $content : wp_json_encode( $content ),
2117 'ai_applied' => 0,
2118 'images_replaced' => 0,
2119 );
2120 }
2121
2122 if ( null !== $payload ) {
2123 $ai_applied = Wdkit_Ai_Content::merge_elementor( $tree, $payload );
2124 }
2125
2126 $encoded = wp_json_encode( $tree );
2127
2128 /* Same warm-up the block branch has, on the same terms - off unless a caller asks
2129 * for it (see warm_media_eagerly()). It was never wired into this path at all. */
2130 $prefetch = $this->warm_media_eagerly()
2131 ? Wdkit_Import_Media::prefetch( $tree, 'elementor', 12, $encoded )
2132 : array();
2133
2134 $map = $this->image_map( $tree, 'elementor' );
2135 $final = Wdkit_Import_Media::replace_urls_in_json( $encoded, $map );
2136
2137 /* The template's OWN media - which this path never localised at all.
2138 *
2139 * transform_elementor() substituted the stock/team images and stopped there, so
2140 * every image the kit itself ships stayed pointed at the template CDN forever: no
2141 * attachment, no id, nothing for the deferred sweep to find, and a site that breaks
2142 * the day those URLs move. The block path had this via wdkit_media_import() from the
2143 * start; this is the same call, on the same content, for the other builder.
2144 *
2145 * $defer_media = true, matching what the bundle importer passes: rasters the
2146 * warm-up above did not already localise are recorded for the background sweep
2147 * rather than downloaded here. It also does the two things only this function knows
2148 * to do - re-key every element id so two pages cannot collide, and localise SVGs
2149 * (concurrently) because the cron has no user and so cannot upload them.
2150 */
2151 $api = class_exists( 'Wdkit_Api_Call' ) ? Wdkit_Api_Call::get_instance() : null;
2152
2153 if ( null !== $api && method_exists( $api, 'wdkit_media_import' ) ) {
2154 $imported = $api->wdkit_media_import( $final, 'elementor', true );
2155
2156 if ( is_array( $imported ) ) {
2157 $re_encoded = wp_json_encode( $imported );
2158
2159 /* Only accept a result that survived the round trip. A json_encode failure
2160 * here would replace the page with the string "false". */
2161 if ( is_string( $re_encoded ) && '' !== $re_encoded ) {
2162 $final = $re_encoded;
2163 }
2164 }
2165 }
2166
2167 if ( class_exists( 'Wdkit_Import_Log' ) ) {
2168 Wdkit_Import_Log::add(
2169 'phases',
2170 array(
2171 'urls' => isset( $prefetch['urls'] ) ? (int) $prefetch['urls'] : 0,
2172 'fetched' => isset( $prefetch['fetched'] ) ? (int) $prefetch['fetched'] : 0,
2173 )
2174 );
2175 }
2176
2177 return array(
2178 'content' => $final,
2179 'ai_applied' => $ai_applied,
2180 'images_replaced' => count( $map ),
2181 );
2182 }
2183
2184 /**
2185 * Gutenberg: parse_blocks → merge → media import → Nexter → serialize_blocks.
2186 *
2187 * The order matches `import_page_section_content()`: media is imported after the
2188 * content is final, then the Nexter processor runs, then the markup is serialised and
2189 * unicode-normalised.
2190 *
2191 * @param array $decoded Decoded template payload.
2192 * @param array|null $payload Canonical AI payload.
2193 * @return array
2194 */
2195 private function transform_gutenberg( $decoded, $payload ) {
2196 $content = isset( $decoded['content'] ) ? (string) $decoded['content'] : '';
2197
2198 if ( '' === $content ) {
2199 return array(
2200 'content' => '',
2201 'ai_applied' => 0,
2202 'images_replaced' => 0,
2203 );
2204 }
2205
2206 /* NOT stripslashes(). The AJAX path pairs `wp_slash( $post_content->content )` with
2207 * `parse_blocks( stripslashes( $content ) )` - a slash/unslash round trip that nets out
2208 * to the original string. Here the content arrives straight off the cloud response,
2209 * already unslashed, so stripping a level that was never added destroys the one escape
2210 * block markup depends on.
2211 *
2212 * A double hyphen is not legal inside an HTML comment, so serialize_block_attributes()
2213 * writes it as the six literal characters \u002d (backslash u 0 0 2 d) twice. The kit's
2214 * markup arrives with 552 of those and every one is a global reference:
2215 * var(\u002d\u002dtpgb-C7). stripslashes() ate the backslashes, so parse_blocks() read the
2216 * attribute as var(u002du002dtpgb-C7) and the generated page CSS carried that straight
2217 * through as color:var(u002du002dtpgb-C7) - every global colour on every imported page
2218 * silently unresolved, and no longer recognisable to the browser's
2219 * Extract_gutenberg_global() rewrite either.
2220 */
2221 $blocks = parse_blocks( $content );
2222 $ai_applied = 0;
2223
2224 if ( null !== $payload ) {
2225 $ai_applied = Wdkit_Ai_Content::merge_gutenberg_blocks( $blocks, $payload );
2226 }
2227
2228 /* Image substitution runs on the attribute objects, so it happens while the content
2229 * is still a block array — string substitution over serialised markup would also hit
2230 * the saved innerHTML, which is exactly what we do not want to rewrite blindly. */
2231 $t_pre = microtime( true );
2232
2233 /* Before image_map(), deliberately. The map measures candidate images to match them
2234 * by aspect ratio, and measuring a remote URL is an HTTP round trip each. Once these
2235 * files are local that becomes a disk read.
2236 *
2237 * Off by default now - see warm_media_eagerly() for the measurement that changed
2238 * it. image_map() still works without it: it only measures the STOCK candidates the
2239 * visitor picked, which the wizard already copied in locally, and remote_sizes
2240 * measured 0 on a full kit either way. */
2241 $prefetch = $this->warm_media_eagerly()
2242 ? Wdkit_Import_Media::prefetch( $blocks, 'gutenberg', 12, $content )
2243 : array();
2244
2245 $t_media = microtime( true );
2246
2247 $map = $this->image_map( $blocks, 'gutenberg' );
2248
2249 if ( ! empty( $map ) ) {
2250 $this->replace_block_image_urls( $blocks, $map );
2251 }
2252
2253 $t_map = microtime( true );
2254
2255 $api = class_exists( 'Wdkit_Api_Call' ) ? Wdkit_Api_Call::get_instance() : null;
2256
2257 /* Warm the media library first, concurrently. wdkit_media_import() below downloads
2258 * one image at a time and generates every thumbnail size as it goes, which is where
2259 * essentially all of an import's time went. Anything prefetched here it finds
2260 * already local and skips. Purely an accelerator: if it returns nothing, the serial
2261 * path runs exactly as before.
2262 *
2263 * $defer_media = true, the same value the bundle importer passes. The two work
2264 * together rather than overlapping: wdkit_Import_media() resolves an already-stored
2265 * image from its source-url hash BEFORE it considers deferring, so everything the
2266 * warm-up above localised is a database lookup here, and only what it missed - a
2267 * URL in a shape the walk does not know, an oversized original, a host that refused
2268 * the parallel fetch - is handed to the background sweep instead of being
2269 * downloaded and resized on the critical path at ~0.8s each. */
2270 if ( null !== $api && method_exists( $api, 'wdkit_media_import' ) ) {
2271 $blocks = $api->wdkit_media_import( $blocks, 'gutenberg', true );
2272 }
2273
2274 $t_after_media = microtime( true );
2275
2276 if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
2277 $processor = new WDKIT_Nexter_Block_Processor();
2278 $blocks = $processor->run( $blocks );
2279 }
2280
2281 $t_nexter = microtime( true );
2282
2283 /* Point the kit's global references at the ids this site assigned. Runs on the parsed
2284 * blocks, so it is done before the markup exists rather than by handing the saved page
2285 * back to the browser afterwards. */
2286 $moved = class_exists( 'Wdkit_Import_Globals' )
2287 ? Wdkit_Import_Globals::rewrite_kit_references( $blocks, isset( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(), $this->session_id )
2288 : array();
2289
2290 $t_globals = microtime( true );
2291
2292 $serialized = serialize_blocks( $blocks );
2293
2294 $t_serialize = microtime( true );
2295
2296 if ( class_exists( 'Wdkit_Import_Log' ) ) {
2297 Wdkit_Import_Log::add(
2298 'phases',
2299 array(
2300 'prefetch_ms' => (int) round( ( $t_media - $t_pre ) * 1000 ),
2301 'map_ms' => (int) round( ( $t_map - $t_media ) * 1000 ),
2302 'remote_sizes' => count( Wdkit_Import_Media::$remote_size_calls ),
2303 'media_ms' => (int) round( ( $t_after_media - $t_map ) * 1000 ),
2304 'nexter_ms' => (int) round( ( $t_nexter - $t_after_media ) * 1000 ),
2305 'globals_ms' => (int) round( ( $t_globals - $t_nexter ) * 1000 ),
2306 'serialize_ms' => (int) round( ( $t_serialize - $t_globals ) * 1000 ),
2307 'urls' => isset( $prefetch['urls'] ) ? (int) $prefetch['urls'] : 0,
2308 'fetched' => isset( $prefetch['fetched'] ) ? (int) $prefetch['fetched'] : 0,
2309 )
2310 );
2311 }
2312
2313 /* The per-property typography variables live in innerHTML too, so they are renumbered
2314 * on the serialised string. */
2315 if ( ! empty( $moved ) ) {
2316 $serialized = Wdkit_Import_Globals::renumber_typo_vars( $serialized, $moved );
2317 }
2318
2319 if ( null !== $api && method_exists( $api, 'replace_unicode_glitch' ) ) {
2320 $serialized = $api->replace_unicode_glitch( $serialized );
2321 }
2322
2323 return array(
2324 'content' => $serialized,
2325 'ai_applied' => $ai_applied,
2326 'images_replaced' => count( $map ),
2327 );
2328 }
2329
2330 /**
2331 * Rewrite image URLs inside parsed block attributes.
2332 *
2333 * Walks `attrs` and `innerBlocks` and replaces any string that the map knows about,
2334 * including inside nested attribute objects and repeaters. Also rewrites `innerHTML`
2335 * and `innerContent`, because a block's saved markup embeds the same URL and would
2336 * otherwise still point at the cloud.
2337 *
2338 * @param array $blocks Parsed blocks (by reference).
2339 * @param array $map old URL => new URL.
2340 * @return void
2341 */
2342 private function replace_block_image_urls( &$blocks, $map ) {
2343 if ( ! is_array( $blocks ) ) {
2344 return;
2345 }
2346
2347 foreach ( $blocks as &$block ) {
2348 if ( ! is_array( $block ) ) {
2349 continue;
2350 }
2351
2352 if ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
2353 $block['attrs'] = self::replace_in_structure( $block['attrs'], $map );
2354 }
2355
2356 foreach ( array( 'innerHTML', 'innerContent' ) as $key ) {
2357 if ( ! isset( $block[ $key ] ) ) {
2358 continue;
2359 }
2360
2361 if ( is_string( $block[ $key ] ) ) {
2362 $block[ $key ] = Wdkit_Import_Media::replace_urls_in_json( $block[ $key ], $map );
2363 } elseif ( is_array( $block[ $key ] ) ) {
2364 foreach ( $block[ $key ] as $i => $chunk ) {
2365 if ( is_string( $chunk ) ) {
2366 $block[ $key ][ $i ] = Wdkit_Import_Media::replace_urls_in_json( $chunk, $map );
2367 }
2368 }
2369 }
2370 }
2371
2372 if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
2373 $this->replace_block_image_urls( $block['innerBlocks'], $map );
2374 }
2375 }
2376
2377 unset( $block );
2378 }
2379
2380 /**
2381 * Recursively swap known URLs inside a decoded structure.
2382 *
2383 * @param mixed $value Structure.
2384 * @param array $map old URL => new URL.
2385 * @return mixed
2386 */
2387 private static function replace_in_structure( $value, $map ) {
2388 if ( is_string( $value ) ) {
2389 return isset( $map[ $value ] ) ? $map[ $value ] : $value;
2390 }
2391
2392 if ( ! is_array( $value ) ) {
2393 return $value;
2394 }
2395
2396 foreach ( $value as $key => $child ) {
2397 $value[ $key ] = self::replace_in_structure( $child, $map );
2398 }
2399
2400 return $value;
2401 }
2402
2403 /**
2404 * Build the old => new image map for one template.
2405 *
2406 * Stock slots are matched to the visitor's pool by aspect ratio; team slots are sourced
2407 * from the team library. Both are sideloaded first, so stored content points at local
2408 * attachments. A slot with no usable replacement is left alone.
2409 *
2410 * @param array $tree Decoded content (element tree or parsed blocks).
2411 * @param string $builder Builder.
2412 * @return array
2413 */
2414 private function image_map( $tree, $builder ) {
2415 $slots = Wdkit_Import_Media::extract_image_urls( $tree, $builder );
2416 $map = array();
2417
2418 if ( ! empty( $slots['ai'] ) && ! empty( $this->context['images'] ) ) {
2419 /* Warm the stock slots concurrently before map_stock_images() measures them.
2420 * It calls getimagesize() over HTTP one slot at a time to get the aspect ratio;
2421 * the importing browser does the same with `new Image()` but all at once. A
2422 * local read after this is effectively free, and prefetch_urls() skips subsizes
2423 * (cron regenerates them), so the download is the only cost - bounded, and for
2424 * files the engine's deferred sweep would have fetched anyway. */
2425 Wdkit_Import_Media::prefetch_urls( array_values( array_filter( $slots['ai'], 'is_string' ) ), 12 );
2426
2427 $urls = wp_list_pluck( $this->context['images'], 'url' );
2428 $local = Wdkit_Import_Media::sideload_batch( $urls );
2429
2430 $pool = array();
2431
2432 foreach ( $this->context['images'] as $image ) {
2433 if ( empty( $local['map'][ $image['url'] ] ) ) {
2434 continue;
2435 }
2436
2437 $entry = array( 'url' => $local['map'][ $image['url'] ] );
2438
2439 if ( ! empty( $image['width'] ) ) {
2440 $entry['width'] = (int) $image['width'];
2441 }
2442
2443 if ( ! empty( $image['height'] ) ) {
2444 $entry['height'] = (int) $image['height'];
2445 }
2446
2447 $pool[] = $entry;
2448 }
2449
2450 $map = array_merge( $map, Wdkit_Import_Media::map_stock_images( $slots['ai'], $pool ) );
2451 }
2452
2453 foreach ( ( ! empty( $slots['ai_team'] ) ? $slots['ai_team'] : array() ) as $group ) {
2454 $images = Wdkit_Import_Team_Images::fetch( $group, $this->context );
2455
2456 if ( empty( $images ) ) {
2457 continue;
2458 }
2459
2460 $local = Wdkit_Import_Media::sideload_batch( $images );
2461
2462 if ( empty( $local['map'] ) ) {
2463 continue;
2464 }
2465
2466 /* Preserve library order: sideload_batch keys by source URL, and the browser
2467 * fills slots in the order the library returned them. */
2468 $ordered = array();
2469
2470 foreach ( $images as $source ) {
2471 if ( ! empty( $local['map'][ $source ] ) ) {
2472 $ordered[] = $local['map'][ $source ];
2473 }
2474 }
2475
2476 $map = array_merge( $map, Wdkit_Import_Media::map_team_images( $group['imgs'], $ordered ) );
2477 }
2478
2479 return $map;
2480 }
2481
2482 /**
2483 * Create the post and attach the prepared content.
2484 *
2485 * @param array $template Template record.
2486 * @param array $prepared Output of transform().
2487 * @param array $decoded Decoded template payload, for custom meta.
2488 * @param array $overrides Optional {title, categories, tags, featured_image}.
2489 * @return array
2490 * @throws Wdkit_Import_Exception
2491 */
2492 private function store( $template, $prepared, $decoded, $overrides = array() ) {
2493 $builder = $this->builder();
2494 $overrides = is_array( $overrides ) ? $overrides : array();
2495 $title = '' !== $template['title'] ? explode( '|', $template['title'] )[0] : __( 'Imported Page', 'wdesignkit' );
2496
2497 /* The AI title, when there is one. Same precedence as import_post_json(), which
2498 * assigns `new_json.title` only when the record matched. */
2499 if ( ! empty( $overrides['title'] ) && is_string( $overrides['title'] ) ) {
2500 $title = $overrides['title'];
2501 }
2502
2503 $post_id = wp_insert_post(
2504 array(
2505 'post_title' => $title,
2506 'post_name' => sanitize_title( $title ),
2507 'post_status' => 'publish',
2508 'post_type' => ! empty( $template['wp_post_type'] ) ? $template['wp_post_type'] : 'page',
2509 'post_content' => ( 'gutenberg' === $builder ) ? $prepared['content'] : '',
2510 ),
2511 true
2512 );
2513
2514 if ( is_wp_error( $post_id ) ) {
2515 throw Wdkit_Import_Errors::from_wp_error( $post_id, 'insert_' . $template['id'] );
2516 }
2517
2518 if ( 'elementor' === $builder ) {
2519 /* Point this page's `__globals__` references at the ids the globals will actually
2520 * be written under. Only does anything when a kit global's `_id` collided with one
2521 * already on this site — see Wdkit_Import_Globals::elementor_id_map(), which
2522 * derives the same map from the same inputs in stage_setup, so the two agree
2523 * without either having to store it.
2524 *
2525 * Here rather than in prepare(): the content is still a string at this point, the
2526 * swap is one str_replace over it, and doing it immediately before the write means
2527 * no later step can reintroduce an old id. */
2528 if ( class_exists( 'Wdkit_Import_Globals' ) ) {
2529 $prepared['content'] = Wdkit_Import_Globals::rewrite_elementor_global_ids(
2530 $prepared['content'],
2531 Wdkit_Import_Globals::elementor_id_map(
2532 isset( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(),
2533 $this->session_id
2534 )
2535 );
2536 }
2537
2538 update_post_meta( $post_id, '_elementor_data', wp_slash( $prepared['content'] ) );
2539 update_post_meta( $post_id, '_elementor_edit_mode', 'builder' );
2540 update_post_meta( $post_id, '_elementor_template_type', 'page' );
2541 }
2542
2543 $this->restore_custom_meta( $post_id, $decoded );
2544 $this->apply_overrides( $post_id, $overrides );
2545
2546 return array(
2547 'template_id' => $template['id'],
2548 'post_id' => (int) $post_id,
2549 'title' => $title,
2550 'url' => get_permalink( $post_id ),
2551 'ai_applied' => $prepared['ai_applied'],
2552 'images_replaced' => $prepared['images_replaced'],
2553
2554 /* Empty on a normal import and on an AI import that needed no generation. */
2555 'credits' => $this->credits,
2556
2557 /* Carried so the setup stage can enable exactly the widgets this content uses.
2558 * Same field wdkit_create_full_site collects for its enable_widgets step. */
2559 'widget_list' => ( ! empty( $decoded['widget_list'] ) && is_array( $decoded['widget_list'] ) )
2560 ? array_values( array_filter( $decoded['widget_list'], 'is_string' ) )
2561 : array(),
2562
2563 /* The setup stage collects global colours/typography from these, and the
2564 * theme-builder condition rewrite needs the post type. */
2565 'global_data' => ( ! empty( $decoded['global_data'] ) && is_array( $decoded['global_data'] ) )
2566 ? $decoded['global_data']
2567 : array(),
2568 'post_type' => ! empty( $template['wp_post_type'] ) ? $template['wp_post_type'] : 'page',
2569
2570 /* The post id this template had on the site it was authored on - NOT the cloud
2571 * template id. Nexter theme-builder display rules are stored as `post-<id>` against
2572 * that authoring id ("show this header on post-15"), so remapping them needs
2573 * `page_id` from the payload. import_new_temp() reads exactly this field
2574 * (`new_json?.page_id`) for the same purpose. Feeding template_id in here instead
2575 * meant the remap searched for `post-21587`, never matched `post-15`, and every
2576 * imported header and footer kept a rule pointing at a post on somebody else's
2577 * site - so none of them rendered anywhere. */
2578 'old_page_id' => isset( $decoded['page_id'] ) ? (string) $decoded['page_id'] : '',
2579 );
2580 }
2581
2582 /**
2583 * Assign the post's taxonomy and featured image.
2584 *
2585 * Mirrors what `import_page_section` does with its `category_list`, `tag_list` and
2586 * `thumb_image` fields, including the SSRF check before the sideload — the difference
2587 * being that the ids here were resolved from names by Wdkit_Import_Taxonomy rather than
2588 * arriving in `$_POST`.
2589 *
2590 * Every failure is swallowed: a post that exists with the wrong thumbnail is a better
2591 * outcome than a run that dies after inserting it.
2592 *
2593 * @param int $post_id Post id.
2594 * @param array $overrides Override set.
2595 * @return void
2596 */
2597 private function apply_overrides( $post_id, $overrides ) {
2598 if ( empty( $overrides ) ) {
2599 return;
2600 }
2601
2602 if ( ! empty( $overrides['categories'] ) && is_array( $overrides['categories'] ) ) {
2603 wp_set_post_terms( $post_id, array_map( 'intval', $overrides['categories'] ), 'category', false );
2604 }
2605
2606 if ( ! empty( $overrides['tags'] ) && is_array( $overrides['tags'] ) ) {
2607 wp_set_post_terms( $post_id, array_map( 'intval', $overrides['tags'] ), 'post_tag', false );
2608 }
2609
2610 if ( empty( $overrides['featured_image'] ) || ! is_string( $overrides['featured_image'] ) ) {
2611 return;
2612 }
2613
2614 /* Wdkit_Import_Media::sideload() returns the local URL, not an id, because that is
2615 * what the URL-substitution path needs. set_post_thumbnail() needs the id, so it is
2616 * resolved back — and this is also what makes the sideload's own duplicate guard
2617 * useful here: a second post pointing at the same image reuses the attachment. */
2618 /* Most featured images are one of the visitor's picked images, which the pages have
2619 * normally imported already - reuse that copy rather than downloading another. */
2620 $reuse = Wdkit_Import_Media::attachment_for_source_url( $overrides['featured_image'] );
2621
2622 if ( $reuse > 0 ) {
2623 set_post_thumbnail( $post_id, $reuse );
2624 return;
2625 }
2626
2627 $sideloaded = Wdkit_Import_Media::sideload( $overrides['featured_image'] );
2628
2629 if ( empty( $sideloaded['success'] ) || empty( $sideloaded['url'] ) ) {
2630 return;
2631 }
2632
2633 $attachment_id = attachment_url_to_postid( $sideloaded['url'] );
2634
2635 if ( $attachment_id > 0 ) {
2636 set_post_thumbnail( $post_id, $attachment_id );
2637 }
2638 }
2639
2640 /**
2641 * Restore the kit's own `nxt-*` post meta (theme-builder conditions and friends).
2642 *
2643 * Uses the same defensive unserialize as the existing importers: an options array
2644 * cannot be passed to maybe_unserialize(), so unserialize() with
2645 * allowed_classes => false is used to avoid object injection (CWE-502).
2646 *
2647 * @param int $post_id Post id.
2648 * @param array $decoded Decoded template payload.
2649 * @return void
2650 */
2651 private function restore_custom_meta( $post_id, $decoded ) {
2652 if ( empty( $decoded['custom_meta'] ) || ! is_array( $decoded['custom_meta'] ) ) {
2653 return;
2654 }
2655
2656 foreach ( $decoded['custom_meta'] as $meta_key => $meta_val ) {
2657 $value = isset( $meta_val[0] ) ? $meta_val[0] : null;
2658
2659 if ( null === $value ) {
2660 continue;
2661 }
2662
2663 if ( is_string( $value ) && is_serialized( $value ) ) {
2664 $value = unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
2665 }
2666
2667 update_post_meta( $post_id, $meta_key, $value );
2668 }
2669 }
2670 }
2671 }
2672