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

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

2,923 lines 111.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PHP import runner.
4 *
5 * Executes an import in the same four stages the browser importer reports, without a browser:
6 *
7 * install_plugin_theme → plugin/theme dependencies
8 * importing_site_content → per template: fetch, AI merge, image substitution, insert
9 * setting_up_site → site name/tagline, front page
10 * finalizing_site_setting → widgets, cleanup
11 *
12 * ── What this is and is not ─────────────────────────────────────────────────
13 *
14 * It is the engine behind `wdkit_handle_create_full_site()` — the PHP-side full-site import
15 * entry point that the WP Abilities / MCP surface already exposed. It is NOT wired into the
16 * browser wizard: import_loader.js still orchestrates its own ~25 admin-ajax calls exactly as
17 * before, and nothing in this file is reachable from that path.
18 *
19 * There is no endpoint of its own, no REST route, no polling and no webhook. A caller reaches
20 * it either through that existing filter or by calling `for_context()` directly in PHP.
21 *
22 * The stage names match import_loader.js deliberately, so progress from either path means the
23 * same thing. `wdkit_import_runner_stage` is fired at every stage boundary so an entry point
24 * can republish progress in its own vocabulary.
25 *
26 * ── Deliberate limits, stated plainly ───────────────────────────────────────
27 *
28 * Implemented: kit dependency resolution and plugin/theme install, optional site reset,
29 * taxonomy, page and section creation for both builders, AI merge from a pre-generated
30 * document, stock and team image substitution with sideload, global colours and typography,
31 * plugin/theme/site settings, navigation rewriting, theme-builder condition rewriting,
32 * WooCommerce products, blog posts, widget enabling, guarded cleanup, resumable per-item
33 * progress, AI payload cleanup.
34 *
35 * Verified against real WordPress: page import (both builders), AI merge, failure/resume/
36 * idempotency, and the destructive-cleanup guards. NOT verified at runtime: product creation
37 * and blog-post creation (environment blocked — no WooCommerce, and the post count gate).
38 *
39 * NOT implemented, and deliberately so:
40 *
41 * Calling the AI generators. `wkit_generate_product_data` / `wkit_generate_post_data` /
42 * `ai/template_import_batch` are cloud calls tied to a user token and credit balance.
43 * The runner consumes an already-generated document instead — which is the point: a
44 * remote caller generates once, on its own account, then hands the result over.
45 *
46 * `reset_site` IS implemented (Wdkit_Import_Reset) but defaults to false. It is destructive to
47 * content the importer did not create, so it only runs when a caller explicitly asks.
48 *
49 * @package Wdesignkit
50 * @since 2.6.5
51 */
52
53 if ( ! defined( 'ABSPATH' ) ) {
54 exit;
55 }
56
57 if ( ! class_exists( 'Wdkit_Import_Runner' ) ) {
58
59 /**
60 * Stage-based PHP importer.
61 */
62 class Wdkit_Import_Runner {
63
64 /** Stage names, shared with the browser importer. */
65 const STAGE_DEPENDENCIES = 'install_plugin_theme';
66 const STAGE_CONTENT = 'importing_site_content';
67 const STAGE_SETUP = 'setting_up_site';
68 const STAGE_FINALIZE = 'finalizing_site_setting';
69
70 /**
71 * Wall-clock budget for ONE stage request, in seconds.
72 *
73 * The wizard drives the runner over admin-ajax, so a stage request is bound by PHP's
74 * max_execution_time — 30s on a default install. A 13-template kit with media does not
75 * fit, and the request dies inside GD/Imagick with "Maximum execution time exceeded",
76 * which the browser sees as a failed stage.
77 *
78 * So a stage does as much as it can inside this budget and reports `complete => false`
79 * with what is left; the browser calls the same stage again. Every item already has its
80 * own session guard, so resuming mid-stage re-does nothing. The old importer stayed
81 * under the limit by making ~25 small requests — this achieves the same thing without
82 * giving up the 4-row progress UI.
83 *
84 * Deliberately well under 30s: one more template must be able to finish after the check.
85 */
86 const STAGE_BUDGET = 8;
87
88 /**
89 * Hardest bound on one stage request: how many templates it may take on.
90 *
91 * The time budget alone is not enough. It is checked BETWEEN templates, so a template
92 * that starts just under the deadline still runs to completion — and one template with
93 * media costs 5-9s. Measured slices with an 8s budget and no cap ran 11s to 39.5s; the
94 * 39.5s one only survived because set_time_limit(0) happened to work on that host.
95 *
96 * ONE. Measured: with a cap of 2 the slices were mostly 1.5-3.4s, but one hit 28.8s —
97 * a single media-heavy template can cost nearly the whole limit by itself, so any cap
98 * above one leaves a pathological pair able to blow it. At one template per request the
99 * worst case IS one template, which is exactly the granularity the existing browser
100 * importer has always used (one import_page_section call per template). More requests,
101 * each no larger than what already works in production.
102 *
103 * ── Raised, because what made a template expensive is gone ──────────────────
104 *
105 * That 28.8s template was expensive for two reasons, and the merge removed both:
106 *
107 * - it fetched its own payload from the cloud (one assembled round trip per
108 * template). The kit now arrives in ONE response, primed before the loop starts by
109 * Wdkit_Page_Importer::prime_bundle(), so a template's fetch is an array lookup.
110 * - it downloaded and resized every image it referenced, inside the request. Media is
111 * now warmed concurrently and anything left is handed to the background sweep, the
112 * same way the bundle importer does it.
113 *
114 * With those gone the cap was costing an HTTP request and a PHP bootstrap per template
115 * to protect against a cost that no longer exists - a 13-template kit paid 13 round
116 * trips to import 13 cheap templates, serially, which is precisely the "old flow
117 * finishes in a minute, new flow takes five" gap that RUNNER_LANES was reverted over.
118 *
119 * So the TIME BUDGET is the bound now, and this is only a ceiling on how much one
120 * request will take on. It is deliberately larger than any kit's template count so that
121 * a normal kit finishes in one or two requests, while out_of_time() still stops a slow
122 * host mid-kit and hands the rest to the next request exactly as before. The worst case
123 * is unchanged in shape - budget, plus one template that started inside it.
124 */
125 const STAGE_ITEM_CAP = 25;
126
127 /**
128 * Second, more generous ceiling for a template whose AI copy prime_ai_batch() already
129 * fetched — merging it and saving the post is a DB write, not a network wait, so it
130 * does not need STAGE_BUDGET's protection. It still needs SOME ceiling, though: giving
131 * it none (ClickUp 14ynqxywpec's stall fix, first pass) let a single stubborn template
132 * push one request to 82s, past what a browser or reverse proxy will wait for before
133 * showing a 504 — even though the PHP process kept running and finished successfully
134 * server-side. Measured recovery of a 14-page kit processed 12 pages in under a
135 * second each once the batch had landed; 15s is generous headroom above that for a
136 * whole slice of them, while staying "deliberately well under 30s" the same way
137 * STAGE_BUDGET's own docblock reasons about it.
138 */
139 const FREE_ANSWER_BUDGET = 15;
140
141 /**
142 * When the current stage must stop taking new work.
143 *
144 * @var float
145 */
146 private $deadline = 0.0;
147
148 /**
149 * When the current stage must stop taking on even a FREE (batch-answered) template.
150 * Armed once, the first time stage_content() finds it has any batch answers to spend
151 * it on — see out_of_free_time().
152 *
153 * @var float
154 */
155 private $free_deadline = 0.0;
156
157 /**
158 * Template ids already attempted in THIS process.
159 *
160 * Only meaningful while run() drains a stage in a loop. A template that failed is not
161 * marked complete, so without this the next loop iteration attempts it again, spends
162 * the item cap on it, and the templates after it are never reached — run() would stop
163 * with later pages missing. Tracking attempts makes the drain terminate.
164 *
165 * It is per-process on purpose: a later run_stage() request, or a user pressing Retry,
166 * starts with an empty list and so DOES re-attempt the failure. That is the difference
167 * between "do not spin on it now" and "never try it again".
168 *
169 * @var array<string,bool>
170 */
171 private $attempted = array();
172
173 /**
174 * Session id this run belongs to.
175 *
176 * @var string
177 */
178 private $session_id = '';
179
180 /**
181 * Validated context.
182 *
183 * @var array
184 */
185 private $context = array();
186
187 /**
188 * @param string $session_id Session id from Wdkit_Import_Session.
189 */
190 public function __construct( $session_id ) {
191 $this->session_id = Wdkit_Import_Session::sanitize_id( $session_id );
192
193 /* Only the context is needed to build the importer, and this runs on every poll —
194 * context() reads the base row without the per-step reconstruction get() does. */
195 $this->context = Wdkit_Import_Session::context( $this->session_id );
196 }
197
198 /**
199 * Create a session for a context and return a runner bound to it.
200 *
201 * @param array $context Raw context — normalised here.
202 * @param string $session_id Optional explicit id.
203 * @return Wdkit_Import_Runner
204 */
205 public static function for_context( $context, $session_id = '' ) {
206 $normalized = Wdkit_Import_Context::normalize( $context );
207 $session = Wdkit_Import_Session::create( $normalized, $session_id );
208
209 return new self( $session['session_id'] );
210 }
211
212 /** @return string */
213 public function get_session_id() {
214 return $this->session_id;
215 }
216
217 /**
218 * Run every stage that has not already completed.
219 *
220 * Re-invoking after a failure or an interruption resumes: completed stages are skipped
221 * because their step flags are already persisted, so nothing is imported twice.
222 *
223 * @return array{success:bool,session:array}
224 */
225 public function run() {
226 $session = Wdkit_Import_Session::get( $this->session_id );
227
228 if ( null === $session ) {
229 return array(
230 'success' => false,
231 'session' => array(),
232 );
233 }
234
235 $errors = Wdkit_Import_Context::validation_errors( $this->context );
236
237 if ( ! empty( $errors ) ) {
238 Wdkit_Import_Session::fail(
239 $this->session_id,
240 array(
241 'code' => 'invalid_context',
242 'message' => implode( ' ', $errors ),
243 'step' => '',
244 'class' => 'non_retryable',
245 'retryable' => false,
246 'skippable' => false,
247 'context' => array(),
248 )
249 );
250
251 return array(
252 'success' => false,
253 'session' => Wdkit_Import_Session::get( $this->session_id ),
254 );
255 }
256
257 Wdkit_Import_Session::start( $this->session_id );
258
259 $this->seed_ai_payloads();
260
261 foreach ( $this->stages() as $stage => $method ) {
262 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $stage ) ) {
263 continue;
264 }
265
266 Wdkit_Import_Session::set_stage( $this->session_id, $stage );
267
268 /**
269 * Fires as a runner stage begins / ends / fails.
270 *
271 * Exists so an entry point can publish progress in its own vocabulary without
272 * reaching into the runner. wdkit_handle_create_full_site() uses it to emit the
273 * `wdkit_site_step` events it has always emitted.
274 *
275 * @param string $stage Stage name.
276 * @param string $state 'start'|'done'|'fail'.
277 * @param array $data Stage result or error record.
278 * @param string $session_id Session id.
279 */
280 do_action( 'wdkit_import_runner_stage', $stage, 'start', array(), $this->session_id );
281
282 /* Stage timing. Wdkit_Import_Log::report() has always had a STAGES section and
283 * it has always read zero on this path, because nothing emitted the entries it
284 * reads — so a headless run could be measured per template but not per stage.
285 * That blind spot is why "where did the other two minutes go" could not be
286 * answered from the data. */
287 $stage_started = microtime( true );
288
289 try {
290 /* run() performs a whole import in ONE call — that is its contract, and
291 * wdkit_create_full_site() depends on it. So drain the stage here: keep
292 * calling it while it reports work still queued.
293 *
294 * The slicing exists for run_stage(), where a browser request is bound by
295 * max_execution_time. Letting the cap leak into run() made it import a
296 * single template and report success, which is exactly what the unit suite
297 * caught. No deadline is armed on this path, so only the item cap paces it.
298 */
299 $this->deadline = 0.0;
300
301 $guard = 0;
302
303 do {
304 $result = $this->{$method}();
305 ++$guard;
306
307 if ( ! empty( $result['pending'] ) ) {
308 do_action( 'wdkit_import_runner_stage', $stage, 'progress', $result, $this->session_id );
309 }
310 } while ( ! empty( $result['pending'] ) && $guard < 500 );
311
312 /* A stage that finished but left individual items failed must NOT be marked
313 * complete, or the next run skips the whole stage and those items are never
314 * retried — which is the difference between a resumable importer and one
315 * that merely does not crash twice. Recording the result and leaving the
316 * stage open is safe because every item inside has its own guard, so a
317 * re-entry re-does only what is still missing. */
318 if ( ! empty( $result['failed'] ) || $this->outstanding_failures( $stage ) > 0 ) {
319 Wdkit_Import_Session::mark_step_failed(
320 $this->session_id,
321 $stage,
322 array(
323 'code' => 'stage_incomplete',
324 'message' => __( 'Some items in this stage did not import.', 'wdesignkit' ),
325 'step' => $stage,
326 'class' => 'retryable',
327 'retryable' => true,
328 'skippable' => true,
329 'context' => array( 'failed' => count( $result['failed'] ) ),
330 )
331 );
332
333 Wdkit_Import_Log::add(
334 'stage',
335 array(
336 'stage' => $stage . ' (partial)',
337 'ms' => (int) round( ( microtime( true ) - $stage_started ) * 1000 ),
338 )
339 );
340
341 do_action( 'wdkit_import_runner_stage', $stage, 'fail', $result, $this->session_id );
342
343 /* No stage-level result is written: every item inside already recorded its
344 * own, and a stage summary that says "3 of 4" would go stale the moment
345 * the fourth lands. */
346 continue;
347 }
348
349 Wdkit_Import_Session::mark_step_complete( $this->session_id, $stage, $result );
350
351 Wdkit_Import_Log::add(
352 'stage',
353 array(
354 'stage' => $stage,
355 'ms' => (int) round( ( microtime( true ) - $stage_started ) * 1000 ),
356 )
357 );
358
359 do_action( 'wdkit_import_runner_stage', $stage, 'done', is_array( $result ) ? $result : array(), $this->session_id );
360
361 /* Stop here if this stage activated a plugin, and let the caller resume in a
362 * fresh request.
363 *
364 * A plugin activated mid-request is only half there: WordPress loads it, but
365 * every hook it registers on `init` and earlier has already fired. Elementor
366 * is the case that proved it — activating it in stage 1 and then using
367 * `Elementor\Plugin::$instance->templates_manager` in stage 3 of the same
368 * request throws, because the templates manager is built on init and init is
369 * long gone. An Elementor kit therefore imported all its pages and then failed
370 * to set the front page, leaving the site 404ing on its own home page.
371 *
372 * The browser never hit this: it installs in one request and imports in
373 * later ones, so the new plugin bootstraps normally in between. Yielding
374 * here reproduces that, using the resume the session already supports —
375 * every completed stage is skipped, so nothing is repeated. */
376 if ( self::STAGE_DEPENDENCIES === $stage && ! empty( $result['installed'] ) ) {
377 return array(
378 'success' => true,
379 'restart' => true,
380 'session' => Wdkit_Import_Session::get( $this->session_id ),
381 );
382 }
383 } catch ( Wdkit_Import_Exception $e ) {
384 $record = Wdkit_Import_Errors::to_record( $e, $stage );
385
386 Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record );
387
388 do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id );
389
390 /* Skippable means this stage could not finish but the run still can.
391 * Anything else stops here with the session left resumable. */
392 if ( ! $e->is_skippable() ) {
393 Wdkit_Import_Session::fail( $this->session_id, $record );
394
395 return array(
396 'success' => false,
397 'session' => Wdkit_Import_Session::get( $this->session_id ),
398 );
399 }
400 } catch ( Throwable $e ) {
401 $record = Wdkit_Import_Errors::to_record( $e, $stage );
402
403 Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record );
404
405 do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id );
406
407 Wdkit_Import_Session::fail( $this->session_id, $record );
408
409 return array(
410 'success' => false,
411 'session' => Wdkit_Import_Session::get( $this->session_id ),
412 );
413 }
414 }
415
416 $session = Wdkit_Import_Session::get( $this->session_id );
417
418 /* A run that got through every stage but left items failed is `partial`, not
419 * `completed` — the difference matters because a caller decides from this whether
420 * to offer a retry. It is still a success: the site imported, some pages did not,
421 * and re-running fixes exactly those. The browser reports the same way, with the
422 * failed rows marked and a retry button beside them. */
423 if ( null !== $session && ! empty( $session['failed'] ) ) {
424 Wdkit_Import_Session::update(
425 $this->session_id,
426 array( 'status' => 'partial' )
427 );
428
429 return array(
430 'success' => true,
431 'session' => Wdkit_Import_Session::get( $this->session_id ),
432 );
433 }
434
435 Wdkit_Import_Session::complete( $this->session_id );
436
437 return array(
438 'success' => true,
439 'session' => Wdkit_Import_Session::get( $this->session_id ),
440 );
441 }
442
443 /**
444 * Run one operation at most once per session.
445 *
446 * Stages 3 and 4 each perform several independent operations. Without a key per
447 * operation, a stage that dies on its fourth call redoes the first three on resume —
448 * harmless for idempotent writes like an option, wasteful for the rest, and wrong for
449 * anything that appends. This makes each one resumable in its own right, which is what
450 * PART 10 of the brief asks for.
451 *
452 * A failure is recorded against the operation's own key and re-thrown, so the stage
453 * still decides whether the run continues.
454 *
455 * @param string $step Step key.
456 * @param callable $operation The work.
457 * @return mixed The operation's result, or the recorded result on a resume.
458 */
459 private function once( $step, $operation ) {
460 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
461 $session = Wdkit_Import_Session::get( $this->session_id );
462
463 return ( null !== $session && isset( $session['results'][ $step ] ) )
464 ? $session['results'][ $step ]
465 : null;
466 }
467
468 /* Timed, because the stage total on its own cannot be acted on. The setup stage is
469 * five sub-steps and one of them dominated it - 129s of a 139s Elementor import -
470 * with nothing in the log to say which. This is the same 'stage' entry shape, one
471 * level down. */
472 $step_started = microtime( true );
473
474 try {
475 $result = $operation();
476 } catch ( Throwable $e ) {
477 Wdkit_Import_Log::add(
478 'step',
479 array(
480 'step' => $step . ' (failed)',
481 'ms' => (int) round( ( microtime( true ) - $step_started ) * 1000 ),
482 )
483 );
484
485 Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, Wdkit_Import_Errors::to_record( $e, $step ) );
486
487 throw $e;
488 }
489
490 Wdkit_Import_Log::add(
491 'step',
492 array(
493 'step' => $step,
494 'ms' => (int) round( ( microtime( true ) - $step_started ) * 1000 ),
495 )
496 );
497
498 Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $result );
499
500 return $result;
501 }
502
503 /**
504 * Fold a freshly-supplied AI document into the stored context, then re-seed.
505 *
506 * ── Why this is needed ─────────────────────────────────────────────────────
507 *
508 * The wizard builds the session's context on its FIRST stage request
509 * (install_plugin_theme). batch_ai_prefetch() in the browser has usually not resolved
510 * by then, so that request carries no `ai_document`. It arrives on a later request -
511 * importing_site_content - but by then __construct() has reloaded the frozen stage-1
512 * context and the new document would be dropped. Without this, every template falls
513 * through to a per-template `ai/template_import` cloud call: 1 wasted batch + N blocking
514 * calls, the exact regression this fixes.
515 *
516 * Merge, not replace: a document that is still filling in (some pages generating) must
517 * not wipe pages an earlier request already carried, and a page already resolved to a
518 * canonical payload wins over a raw one.
519 *
520 * @param array $document A document as Wdkit_Ai_Content::normalize_document() returns it.
521 * @return void
522 */
523 public function adopt_ai_document( $document ) {
524 /* `posts` and `products` count as content too. The guard used to test the two page
525 * groups only, from when page copy was the only thing the browser sent - so a
526 * document carrying the blog-post or product answer but no resolvable page copy
527 * (an AI batch that partially failed, or a wireframe import that skips page copy
528 * entirely) was thrown away at the door, and the posts fell back to the hardcoded
529 * set while the products vanished. */
530 if ( ! is_array( $document ) ) {
531 return;
532 }
533
534 $has_content = false;
535
536 foreach ( array( 'pages', 'raw_pages', 'posts', 'products' ) as $group ) {
537 if ( ! empty( $document[ $group ] ) ) {
538 $has_content = true;
539 break;
540 }
541 }
542
543 if ( ! $has_content ) {
544 return;
545 }
546
547 $current = ( isset( $this->context['ai_document'] ) && is_array( $this->context['ai_document'] ) )
548 ? $this->context['ai_document']
549 : array();
550
551 $before = wp_json_encode( $current );
552
553 foreach ( array( 'pages', 'raw_pages', 'products', 'posts' ) as $group ) {
554 if ( empty( $document[ $group ] ) || ! is_array( $document[ $group ] ) ) {
555 continue;
556 }
557
558 if ( empty( $current[ $group ] ) || ! is_array( $current[ $group ] ) ) {
559 $current[ $group ] = array();
560 }
561
562 foreach ( $document[ $group ] as $key => $value ) {
563 $have = isset( $current[ $group ][ $key ] ) ? $current[ $group ][ $key ] : null;
564
565 /* Keep a page that is already a resolved canonical payload; otherwise take
566 * the incoming one (raw entries have no `elements`, so they always refresh -
567 * harmless, it is the same answer). */
568 if ( is_array( $have ) && ! empty( $have['elements'] ) ) {
569 continue;
570 }
571
572 $current[ $group ][ $key ] = $value;
573 }
574 }
575
576 foreach ( array( 'version', 'builder' ) as $key ) {
577 if ( empty( $current[ $key ] ) && ! empty( $document[ $key ] ) ) {
578 $current[ $key ] = $document[ $key ];
579 }
580 }
581
582 /* Taxonomy needs its own test, not the `empty()` one above. normalize_document()
583 * ALWAYS emits `taxonomy` as array( 'categories' => [], 'tags' => [] ), and an array
584 * with two keys is never empty() - so once a first request had stored that hollow
585 * shape, every later document's real category and tag names were skipped as
586 * "already set". Compare on the lists themselves. */
587 if ( self::taxonomy_is_empty( $current ) && ! self::taxonomy_is_empty( $document ) ) {
588 $current['taxonomy'] = $document['taxonomy'];
589 }
590
591 if ( wp_json_encode( $current ) === $before ) {
592 return;
593 }
594
595 $this->context['ai_document'] = $current;
596
597 Wdkit_Import_Session::update( $this->session_id, array( 'context' => $this->context ) );
598
599 /* Now that the document is in the context, fan it out to per-template files for any
600 * template it can already resolve canonically (the remote-caller shape). The browser
601 * shape stays in raw_pages and is converted per template by the page importer. */
602 $this->seed_ai_payloads();
603 }
604
605 /**
606 * Park the kit's `site_settings` block on the session, once, when the bundle is primed.
607 *
608 * The container width the kit ships lives in that block, and the globals step needs it —
609 * but the two run in DIFFERENT stages, which on the wizard path means different HTTP
610 * requests. `Wdkit_Page_Importer::bundle_site_settings()` is a static, so by the time
611 * `setting_up_site` runs it is empty again and the step had nothing to apply. The
612 * headless path never noticed because it runs all four stages in one process.
613 *
614 * Stored on the context rather than re-primed later so the globals step costs nothing and
615 * a resumed run still has it. Written once — a second call with the same value does not
616 * touch the option.
617 *
618 * @since 2.7.2
619 *
620 * @return void
621 */
622 /**
623 * Plugin-level settings the kit expects. Extracted so the content stage can run it too.
624 *
625 * @since 2.7.2
626 *
627 * @param string $builder 'elementor'|'gutenberg'.
628 * @return array
629 */
630 private function step_plugin_settings( $builder ) {
631 return Wdkit_Import_Settings::apply_plugin( 'elementor' === $builder ? 'elementor' : '' );
632 }
633
634 /**
635 * The kit's palette, typography and site-level settings.
636 *
637 * @since 2.7.2
638 *
639 * @param string $builder 'elementor'|'gutenberg'.
640 * @return array
641 */
642 private function step_globals( $builder ) {
643 /* The kit's own palette takes precedence; globals discovered on the decoded
644 * templates fill any gaps. Using only the templates left the kit's extended
645 * colours (C8, C12, C17...) and typography (T15...) undefined, so every page
646 * referencing them rendered unstyled. */
647 $from_kit = Wdkit_Import_Globals::from_kit(
648 ! empty( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(),
649 $builder,
650 ! empty( $this->context['font_family'] ) ? $this->context['font_family'] : array(),
651 ! empty( $this->context['site_global'] ) ? $this->context['site_global'] : array(),
652 $this->session_id
653 );
654
655 $from_templates = Wdkit_Import_Globals::collect( $this->decoded_templates(), $builder );
656
657 /* Site-level settings are the KIT's, not the globals screen's.
658 *
659 * `context['site_global']` is the colour/typography preset the user confirmed on the
660 * globals screen; it has never carried a container width. The width lives in each
661 * template's own `site_settings` block, which is what the browser path forwards
662 * (`update_site_globals(content.site_settings)`). Passing the preset here instead
663 * meant the write was handed a payload with no container keys in it at all, and
664 * `tpgb_global_options.globalContainer` was overwritten with an empty array — so
665 * Gutenberg imports fell back to Nexter's default 1140px instead of the kit's 1240px.
666 *
667 * Context first: the content stage parked it there (remember_kit_site_settings)
668 * because the live static does not survive into another stage's own request on the
669 * wizard path. The preset stays as the last fallback. */
670 $site_data = ( ! empty( $this->context['kit_site_settings'] ) && is_array( $this->context['kit_site_settings'] ) )
671 ? $this->context['kit_site_settings']
672 : Wdkit_Page_Importer::bundle_site_settings();
673
674 if ( empty( $site_data ) && ! empty( $this->context['site_global'] ) ) {
675 $site_data = $this->context['site_global'];
676 }
677
678 return Wdkit_Import_Globals::apply(
679 Wdkit_Import_Globals::merge_globals( $from_kit, $from_templates, $builder ),
680 $builder,
681 is_array( $site_data ) ? $site_data : array()
682 );
683 }
684
685 /**
686 * Run the settings that belong BEFORE the kit's pages are imported.
687 *
688 * Latest applies plugin settings, the global palette and the theme settings first and
689 * imports the pages after; the runner had them the other way round, so the wizard listed
690 * "Global / Plugin / Theme Settings" below the page rows instead of above them — and,
691 * more than cosmetically, every page was imported before the palette it references
692 * existed. These three depend on nothing the content stage produces, so running them at
693 * the top of it restores latest's order for both the UI and the work itself.
694 *
695 * `site_settings` (which assigns the front page) and `widgets` are NOT hoisted: they need
696 * the pages to exist, and they stay in `setting_up_site` where they always were.
697 *
698 * Every step is `once()`-guarded and memoised, so the later `setting_up_site` call finds
699 * them complete and returns the stored result instead of repeating the work. Only the
700 * first lane to arrive runs them; the rest see a completed step.
701 *
702 * @since 2.7.2
703 *
704 * @param string $builder 'elementor'|'gutenberg'.
705 * @return void
706 */
707 private function settings_before_content( $builder ) {
708 /* once() is memoisation, NOT mutual exclusion — it checks is_step_complete() and
709 * nothing else, which was sufficient while these steps only ever ran from
710 * setting_up_site, a stage the browser drives with a single request. The content
711 * stage runs RUNNER_LANES requests at once, so calling them from here without a lock
712 * had all four lanes enter the same step before any of them had finished it: the run
713 * logged `globals` four times and `theme_settings` three. One claim, and the lanes
714 * that lose it simply get on with importing templates.
715 *
716 * Losing the claim costs them nothing. These steps are ordered ahead of the templates
717 * for the operator's benefit — it is the order every previous release used and the
718 * order the progress list reads in — not because a template import reads them: the
719 * palette is resolved by the browser at render time from plus-global.css, which is
720 * why the previous arrangement (globals AFTER every page) produced correct pages too.
721 * So a lane that proceeds while a sibling is still applying them is not importing
722 * anything differently. */
723 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'theme_settings' ) ) {
724 return;
725 }
726
727 $lock = 'settings_before_content';
728
729 if ( '' !== $this->session_id && ! Wdkit_Import_Session::claim( $this->session_id, $lock ) ) {
730 return;
731 }
732
733 try {
734 $this->once(
735 'plugin_settings',
736 function () use ( $builder ) {
737 return $this->step_plugin_settings( $builder );
738 }
739 );
740
741 $this->once(
742 'globals',
743 function () use ( $builder ) {
744 return $this->step_globals( $builder );
745 }
746 );
747
748 $this->once( 'theme_settings', array( 'Wdkit_Import_Settings', 'apply_theme' ) );
749 } catch ( Throwable $e ) {
750 /* A settings step that fails must not take the content stage down with it — the
751 * pages are the import. once() has already recorded the failure against the step,
752 * so `setting_up_site` still reports it and Retry still reaches it. */
753 Wdkit_Import_Log::add( 'step', array( 'step' => 'settings_before_content (deferred)' ) );
754 } finally {
755 if ( '' !== $this->session_id ) {
756 Wdkit_Import_Session::release( $this->session_id, $lock );
757 }
758 }
759 }
760
761 private function remember_kit_site_settings() {
762 if ( ! class_exists( 'Wdkit_Page_Importer' ) || ! method_exists( 'Wdkit_Page_Importer', 'bundle_site_settings' ) ) {
763 return;
764 }
765
766 $settings = Wdkit_Page_Importer::bundle_site_settings();
767
768 if ( empty( $settings ) || ! is_array( $settings ) ) {
769 return;
770 }
771
772 $current = ( isset( $this->context['kit_site_settings'] ) && is_array( $this->context['kit_site_settings'] ) )
773 ? $this->context['kit_site_settings']
774 : array();
775
776 if ( wp_json_encode( $current ) === wp_json_encode( $settings ) ) {
777 return;
778 }
779
780 $this->context['kit_site_settings'] = $settings;
781
782 if ( '' !== $this->session_id ) {
783 Wdkit_Import_Session::update( $this->session_id, array( 'context' => $this->context ) );
784 }
785 }
786
787 /**
788 * Does this document carry no usable category or tag names?
789 *
790 * True for a missing `taxonomy`, and equally for the hollow
791 * `array( 'categories' => array(), 'tags' => array() )` that normalize_document() always
792 * emits — which is the case `empty()` gets wrong, because a two-key array is not empty.
793 *
794 * @since 2.7.2
795 *
796 * @param array $document Normalised document.
797 * @return bool
798 */
799 private static function taxonomy_is_empty( $document ) {
800 if ( empty( $document['taxonomy'] ) || ! is_array( $document['taxonomy'] ) ) {
801 return true;
802 }
803
804 foreach ( array( 'categories', 'tags' ) as $key ) {
805 if ( ! empty( $document['taxonomy'][ $key ] ) ) {
806 return false;
807 }
808 }
809
810 return true;
811 }
812
813 /**
814 * Write the context's AI document out as per-template payload files.
815 *
816 * The page importer already loads one payload per template from the session store —
817 * that path was built for the case where the browser generates and PHP applies. A
818 * document simply fans out into the same files, so a remote caller supplying one
819 * whole-site payload and a caller supplying per-template payloads reach an identical
820 * merge. Nothing downstream needs to know which happened.
821 *
822 * Idempotent by construction: has_payload() means a resume does not rewrite files, so
823 * an interrupted run keeps whatever it already had on disk.
824 *
825 * @return array{written:int,skipped:int}
826 */
827 private function seed_ai_payloads() {
828 $written = 0;
829 $skipped = 0;
830
831 $document = ! empty( $this->context['ai_document'] ) ? $this->context['ai_document'] : array();
832
833 if ( empty( $document['pages'] ) || ! is_array( $document['pages'] ) ) {
834 return array(
835 'written' => 0,
836 'skipped' => 0,
837 );
838 }
839
840 foreach ( array_keys( $document['pages'] ) as $template_id ) {
841 if ( Wdkit_Ai_Content::has_payload( $this->session_id, $template_id ) ) {
842 ++$skipped;
843 continue;
844 }
845
846 $payload = Wdkit_Ai_Content::page_payload( $document, $template_id );
847
848 if ( null === $payload ) {
849 ++$skipped;
850 continue;
851 }
852
853 if ( Wdkit_Ai_Content::save_payload( $this->session_id, $template_id, $payload ) ) {
854 ++$written;
855 } else {
856 ++$skipped;
857 }
858 }
859
860 return array(
861 'written' => $written,
862 'skipped' => $skipped,
863 );
864 }
865
866 /**
867 * Move the session to its final state once every stage has completed.
868 *
869 * Called after any stage run — including a no-op re-entry — because a resume finishes
870 * by re-entering stages that were already done, and the run has to be allowed to end
871 * on that path too.
872 *
873 * `partial` rather than `completed` when items are still failed: the site imported, some
874 * pages did not, and re-running fixes exactly those. That distinction is what the
875 * wizard reads to decide whether to offer Retry.
876 *
877 * @return void
878 */
879 private function settle_status() {
880 /* The last stage having completed is what ends a run — NOT every stage being clean.
881 * A partial run deliberately leaves the content stage open so its failed templates
882 * can be retried, so requiring all four would leave such a run stuck at `running`
883 * until the shutdown handler mislabelled it `interrupted`. */
884 if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, self::STAGE_FINALIZE ) ) {
885 return;
886 }
887
888 $session = Wdkit_Import_Session::get( $this->session_id );
889
890 if ( null === $session ) {
891 return;
892 }
893
894 /* mark_step_complete() removes a step from `failed` when it succeeds, so anything
895 * still listed genuinely did not land — no filtering needed, and none wanted: a
896 * stage that really failed should hold the run at `partial`. */
897 if ( ! empty( $session['failed'] ) ) {
898 Wdkit_Import_Session::update( $this->session_id, array( 'status' => 'partial' ) );
899
900 return;
901 }
902
903 Wdkit_Import_Session::complete( $this->session_id );
904 }
905
906 /**
907 * Item-level failures still outstanding for a stage.
908 *
909 * A stage's own return value is not enough to decide whether it finished. Both drivers
910 * call a stage repeatedly — run() in a drain loop, run_stage() once per request — and
911 * each call only reports ITS OWN failures. So a later clean call masked an earlier
912 * failure and the stage was marked complete with a page still missing; the resume then
913 * skipped the whole stage. The session is the only place that remembers every failure,
914 * so ask it.
915 *
916 * @param string $stage Stage name.
917 * @return int
918 */
919 private function outstanding_failures( $stage ) {
920 $session = Wdkit_Import_Session::get( $this->session_id );
921
922 if ( null === $session || empty( $session['failed'] ) ) {
923 return 0;
924 }
925
926 /* Which step prefixes belong to which stage. Only the content stage owns per-item
927 * steps; the others are single operations already covered by their own records. */
928 $prefixes = ( self::STAGE_CONTENT === $stage )
929 ? array( 'page_', 'post_', 'product_' )
930 : array();
931
932 if ( empty( $prefixes ) ) {
933 return 0;
934 }
935
936 $count = 0;
937
938 foreach ( array_keys( $session['failed'] ) as $step ) {
939 foreach ( $prefixes as $prefix ) {
940 if ( 0 === strpos( (string) $step, $prefix ) ) {
941 ++$count;
942 break;
943 }
944 }
945 }
946
947 return $count;
948 }
949
950 /**
951 * Has this stage used up its slice?
952 *
953 * Checked BETWEEN items, never inside one, so an item is always either fully done or
954 * not started — which is what keeps the session records truthful.
955 *
956 * @return bool
957 */
958 private function out_of_time() {
959 return ( $this->deadline > 0.0 ) && ( microtime( true ) >= $this->deadline );
960 }
961
962 /**
963 * Has this stage used up the separate, more generous slice a FREE (batch-answered)
964 * template gets? Armed lazily, on the first template stage_content() finds a batch
965 * answer for — arming it any earlier would count the batch call's own network wait
966 * against it, which is exactly the budget out_of_time() already exists to bound
967 * against a template that still needs one.
968 *
969 * Unarmed entirely when $deadline itself is — run()'s "drain this stage to completion
970 * in one PHP process" path (no browser polling, no HTTP round trip per slice to
971 * protect) sets `$this->deadline = 0.0` for exactly that reason, and a free template
972 * deserves the same "nothing here is bounding it" treatment an ordinary one already
973 * gets on that path. Free_deadline is armed once per run_stage() call (reset there
974 * alongside $deadline), so this stays scoped to one request the same way out_of_time()
975 * is.
976 *
977 * @return bool
978 */
979 private function out_of_free_time() {
980 if ( $this->deadline <= 0.0 ) {
981 return false;
982 }
983
984 if ( $this->free_deadline <= 0.0 ) {
985 $this->free_deadline = microtime( true ) + self::FREE_ANSWER_BUDGET;
986
987 return false;
988 }
989
990 return microtime( true ) >= $this->free_deadline;
991 }
992
993 /**
994 * Ordered stage => method map.
995 *
996 * @return array<string,string>
997 */
998 private function stages() {
999 return array(
1000 self::STAGE_DEPENDENCIES => 'stage_dependencies',
1001 self::STAGE_CONTENT => 'stage_content',
1002 self::STAGE_SETUP => 'stage_setup',
1003 self::STAGE_FINALIZE => 'stage_finalize',
1004 );
1005 }
1006
1007 /**
1008 * Run ONE stage and return its outcome.
1009 *
1010 * This is what the Kit Import wizard drives. The wizard has always advanced a stage at
1011 * a time — import_function() has four branches and calls one per screen update — so
1012 * running one stage per request keeps its existing progress UX working without any
1013 * polling: the browser is the scheduler, exactly as before.
1014 *
1015 * Re-entering a completed stage is a no-op that reports success, which is what makes a
1016 * browser refresh mid-import harmless.
1017 *
1018 * @param string $stage Stage name.
1019 * @return array{success:bool,stage:string,complete:bool,result:mixed,error:array,session:array}
1020 */
1021 public function run_stage( $stage ) {
1022 $stage = (string) $stage;
1023 $stages = $this->stages();
1024
1025 if ( ! isset( $stages[ $stage ] ) ) {
1026 return array(
1027 'success' => false,
1028 'stage' => $stage,
1029 'complete' => false,
1030 'result' => null,
1031 'error' => array(
1032 'code' => 'unknown_stage',
1033 'message' => __( 'Unknown import stage.', 'wdesignkit' ),
1034 'retryable' => false,
1035 ),
1036 'session' => Wdkit_Import_Session::get( $this->session_id ),
1037 );
1038 }
1039
1040 $errors = Wdkit_Import_Context::validation_errors( $this->context );
1041
1042 if ( ! empty( $errors ) ) {
1043 $record = array(
1044 'code' => 'invalid_context',
1045 'message' => implode( ' ', $errors ),
1046 'step' => $stage,
1047 'class' => 'non_retryable',
1048 'retryable' => false,
1049 'skippable' => false,
1050 'context' => array(),
1051 );
1052
1053 Wdkit_Import_Session::fail( $this->session_id, $record );
1054
1055 return array(
1056 'success' => false,
1057 'stage' => $stage,
1058 'complete' => false,
1059 'result' => null,
1060 'error' => $record,
1061 'session' => Wdkit_Import_Session::get( $this->session_id ),
1062 );
1063 }
1064
1065 /* Already done on a previous attempt — a refresh, or a double-fired effect. Still
1066 * settle the status: on a resume the later stages are already complete, so without
1067 * this the run never leaves `running` and the shutdown handler marks it
1068 * `interrupted`. Caught by resuming a partial import through the wizard. */
1069 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $stage ) ) {
1070 $this->settle_status();
1071
1072 $session = Wdkit_Import_Session::get( $this->session_id );
1073
1074 return array(
1075 'success' => true,
1076 'stage' => $stage,
1077 'complete' => true,
1078 'result' => isset( $session['results'][ $stage ] ) ? $session['results'][ $stage ] : null,
1079 'error' => array(),
1080 'session' => $session,
1081 );
1082 }
1083
1084 Wdkit_Import_Session::start( $this->session_id );
1085 $this->seed_ai_payloads();
1086 Wdkit_Import_Session::set_stage( $this->session_id, $stage );
1087
1088 /* One slice per request. set_time_limit() is belt-and-braces: many hosts ignore it,
1089 * and the budget is what actually keeps this inside the limit. */
1090 $this->deadline = microtime( true ) + self::STAGE_BUDGET;
1091 $this->free_deadline = 0.0;
1092
1093 if ( function_exists( 'set_time_limit' ) ) {
1094 @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1095 }
1096
1097 do_action( 'wdkit_import_runner_stage', $stage, 'start', array(), $this->session_id );
1098
1099 $method = $stages[ $stage ];
1100
1101 try {
1102 $result = $this->{$method}();
1103 } catch ( Throwable $e ) {
1104 $record = Wdkit_Import_Errors::to_record( $e, $stage );
1105
1106 Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record );
1107
1108 /* Disarm before returning — see the note below. */
1109 Wdkit_Import_Session::unwatch();
1110
1111 do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id );
1112
1113 return array(
1114 'success' => false,
1115 'stage' => $stage,
1116 'complete' => false,
1117 'result' => null,
1118 'error' => $record,
1119 'session' => Wdkit_Import_Session::get( $this->session_id ),
1120 );
1121 }
1122
1123 /* ── Disarm the crash watcher ──────────────────────────────────────
1124 *
1125 * start() arms on_shutdown(), which marks a session that is still `running` when the
1126 * request ends as `interrupted` and records a retryable failure against it. That is
1127 * right for run(), where one request performs all four stages and a request ending
1128 * early really is a crash.
1129 *
1130 * It is wrong here. The wizard runs ONE stage per request, so every intermediate
1131 * request ends with the session legitimately still `running` — and was being
1132 * reported as interrupted, which surfaced in the UI as a failed stage even though
1133 * the stage had completed. Observed in the browser: `install_plugin_theme:done`
1134 * fired server-side while the row went red.
1135 *
1136 * The watcher stays armed for the duration of the stage, so a genuine fatal inside
1137 * the stage is still caught; it is only released once the stage has returned. */
1138 Wdkit_Import_Session::unwatch();
1139
1140 /* Out of time with work still queued: report progress and ask to be called again.
1141 * Not an error and not complete — the browser re-requests the same stage. */
1142 if ( ! empty( $result['pending'] ) ) {
1143 do_action( 'wdkit_import_runner_stage', $stage, 'progress', $result, $this->session_id );
1144
1145 Wdkit_Import_Session::unwatch();
1146
1147 return array(
1148 'success' => true,
1149 'stage' => $stage,
1150 'complete' => false,
1151 'pending' => (int) $result['pending'],
1152 'result' => $result,
1153 'error' => array(),
1154 'session' => Wdkit_Import_Session::get( $this->session_id ),
1155 );
1156 }
1157
1158 /* Same rule as run(): a stage with failed items stays OPEN so those items can be
1159 * retried, but the stage itself still reports success because the run can go on.
1160 * Asked of the session, so a failure from an earlier slice is not forgotten. */
1161 if ( ! empty( $result['failed'] ) || $this->outstanding_failures( $stage ) > 0 ) {
1162 Wdkit_Import_Session::mark_step_failed(
1163 $this->session_id,
1164 $stage,
1165 array(
1166 'code' => 'stage_incomplete',
1167 'message' => __( 'Some items in this stage did not import.', 'wdesignkit' ),
1168 'step' => $stage,
1169 'class' => 'retryable',
1170 'retryable' => true,
1171 'skippable' => true,
1172 'context' => array( 'failed' => count( $result['failed'] ) ),
1173 )
1174 );
1175
1176 do_action( 'wdkit_import_runner_stage', $stage, 'fail', $result, $this->session_id );
1177
1178 return array(
1179 'success' => true,
1180 'stage' => $stage,
1181 'complete' => false,
1182 'result' => $result,
1183 'error' => array(),
1184 'session' => Wdkit_Import_Session::get( $this->session_id ),
1185 );
1186 }
1187
1188 Wdkit_Import_Session::mark_step_complete( $this->session_id, $stage, $result );
1189
1190 do_action( 'wdkit_import_runner_stage', $stage, 'done', is_array( $result ) ? $result : array(), $this->session_id );
1191
1192 $this->settle_status();
1193
1194 return array(
1195 'success' => true,
1196 'stage' => $stage,
1197 'complete' => true,
1198 'result' => $result,
1199 'error' => array(),
1200 'session' => Wdkit_Import_Session::get( $this->session_id ),
1201 );
1202 }
1203
1204 /*
1205 |--------------------------------------------------------------------------
1206 | Stage 1 — dependencies
1207 |--------------------------------------------------------------------------
1208 */
1209
1210 /**
1211 * Install the plugins and theme the kit declares.
1212 *
1213 * The list comes from the kit, never from the context — see the note in
1214 * Wdkit_Import_Context. Reuses Wdkit_Depends_Installer, the same class the
1215 * `install_plugins_depends` AJAX action calls.
1216 *
1217 * @return array
1218 */
1219 private function stage_dependencies() {
1220 /* Reset first, before anything is created. That ordering is what makes an imported
1221 * header/footer the active one: Nexter's renderer has no priority mechanism, so the
1222 * only thing that stops an existing header rendering alongside the new one is having
1223 * been disabled BEFORE the new one is inserted. The wizard achieves the same by
1224 * running its reset in the stage ahead of import_kit(). Off unless asked. */
1225 $reset = $this->once(
1226 'reset_site',
1227 function () {
1228 return Wdkit_Import_Reset::run( $this->context );
1229 }
1230 );
1231
1232 $dependencies = $this->kit_dependencies();
1233
1234 $installed = ! empty( $dependencies )
1235 ? Wdkit_Import_Dependencies::install( $dependencies )
1236 : array(
1237 'installed' => array(),
1238 'already_installed' => array(),
1239 'skipped' => array(),
1240 'failed' => array(),
1241 );
1242
1243 /* Enable the kit's widgets HERE, in stage 1, not in stage_setup where the old
1244 * `widgets` step lives — by then every page is already saved, and Elementor's
1245 * Document::save() has silently dropped every tp-* node whose widget the site did
1246 * not have registered. Stage 1 is a hard barrier before any content stage lane
1247 * runs, and it is after the dependency install that just activated The Plus Addons.
1248 * Best-effort: a kit still imports if this fails, the server's per-template
1249 * `unregistered` report says which widgets each page lost. */
1250 $widgets = $this->once( 'kit_widgets', array( $this, 'prime_and_enable_widgets' ) );
1251
1252 return array( 'reset' => $reset, 'widgets' => $widgets ) + $installed;
1253 }
1254
1255 /**
1256 * Prime the kit bundle and turn on every widget/block its content uses.
1257 *
1258 * Never throws — wrapped so once() records it as a completed step regardless, because a
1259 * widget-enable failure must not block the import.
1260 *
1261 * @return array
1262 */
1263 public function prime_and_enable_widgets() {
1264 $builder = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor';
1265
1266 try {
1267 Wdkit_Page_Importer::prime_bundle(
1268 $this->context,
1269 $builder,
1270 ! empty( $this->context['templates'] ) ? $this->context['templates'] : array()
1271 );
1272
1273 $this->remember_kit_site_settings();
1274
1275 if ( 'gutenberg' === $builder ) {
1276 $blocks = Wdkit_Page_Importer::bundle_block_names();
1277
1278 if ( empty( $blocks ) || ! class_exists( 'Wdkit_Import_Dependencies' ) ) {
1279 return array( 'enabled' => 0, 'builder' => $builder );
1280 }
1281
1282 $res = Wdkit_Import_Dependencies::enable_blocks( $blocks );
1283
1284 return array( 'enabled' => isset( $res['enabled'] ) ? (int) $res['enabled'] : 0, 'requested' => count( $blocks ), 'builder' => 'gutenberg' );
1285 }
1286
1287 $manifest = Wdkit_Page_Importer::bundle_widget_manifest();
1288
1289 if ( ( empty( $manifest['widgets'] ) && empty( $manifest['extensions'] ) ) || ! class_exists( 'Wdkit_Import_Dependencies' ) ) {
1290 return array( 'enabled' => 0, 'builder' => $builder );
1291 }
1292
1293 $res = Wdkit_Import_Dependencies::enable_widgets( $manifest['widgets'], $manifest['extensions'] );
1294
1295 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1296 Wdkit_Import_Log::add(
1297 'widgets',
1298 array(
1299 'widgets' => count( $manifest['widgets'] ),
1300 'extensions' => count( $manifest['extensions'] ),
1301 'ok' => ! empty( $res['success'] ) ? 1 : 0,
1302 )
1303 );
1304 }
1305
1306 return array(
1307 'enabled' => ! empty( $res['success'] ),
1308 'widgets' => count( $manifest['widgets'] ),
1309 'extensions' => count( $manifest['extensions'] ),
1310 'builder' => 'elementor',
1311 );
1312 } catch ( Throwable $e ) {
1313 return array( 'enabled' => 0, 'error' => $e->getMessage() );
1314 }
1315 }
1316
1317 /**
1318 * The kit's own declared dependencies, resolved through the catalogue.
1319 *
1320 * `plugins_id` on each template is the only accepted source — see
1321 * Wdkit_Import_Dependencies. An empty result means nothing gets installed, which is
1322 * the safe failure: an import that installs nothing is recoverable, one that installs
1323 * the wrong thing is not.
1324 *
1325 * @return array[]
1326 */
1327 private function kit_dependencies() {
1328 if ( empty( $this->context['kit_id'] ) ) {
1329 return array();
1330 }
1331
1332 $templates = ! empty( $this->context['templates'] ) ? $this->context['templates'] : array();
1333 $catalogue = $this->plugin_catalogue();
1334
1335 $dependencies = Wdkit_Import_Dependencies::resolve( $templates, $catalogue );
1336
1337 /* The theme, when the wizard's toggle asked for it. The browser does exactly this -
1338 * `if (site_obj?.theme_setting) temp_plugin.push(theme_obj)` in import_loader.js - and
1339 * without it the Nexter theme was never installed and never listed among the
1340 * dependencies, which is what "nexter theme miss in import" was.
1341 *
1342 * The descriptor is fixed here rather than taken from the caller. That keeps the property
1343 * the context docblock is about: a payload can ask for "the theme" but cannot name a
1344 * package to install. Values match theme_obj in import_loader.js. */
1345 if ( ! empty( $this->context['theme_setting'] ) ) {
1346 $already = false;
1347
1348 foreach ( $dependencies as $dependency ) {
1349 if ( isset( $dependency['type'] ) && 'theme' === $dependency['type'] ) {
1350 $already = true;
1351 break;
1352 }
1353 }
1354
1355 if ( ! $already ) {
1356 $dependencies[] = array(
1357 'p_id' => 3001,
1358 'original_slug' => 'nexter',
1359 'plugin_name' => 'Nexter',
1360 'freepro' => '0',
1361 'type' => 'theme',
1362 );
1363 }
1364 }
1365
1366 /* The builder the kit is written for.
1367 *
1368 * A kit's templates list the addons they use but never the builder itself — the
1369 * wizard never needed it, because the builder was already active in the browser
1370 * running the import. Headless there is no such guarantee: an Elementor kit
1371 * imported its fourteen templates onto a site with Elementor installed but
1372 * inactive, and every page rendered blank. The content was all there; nothing was
1373 * there to render it.
1374 *
1375 * Gutenberg is skipped: it is WordPress core, so there is nothing to install. The
1376 * descriptor comes from the catalogue rather than being hardcoded here, so the
1377 * slug and file path stay whatever the catalogue says they are. */
1378 $builder_ids = array(
1379 'elementor' => 1001,
1380 );
1381
1382 $kit_builder = isset( $this->context['builder'] ) ? (string) $this->context['builder'] : '';
1383
1384 if ( isset( $builder_ids[ $kit_builder ] ) ) {
1385 $want = (string) $builder_ids[ $kit_builder ];
1386 $have = false;
1387
1388 foreach ( $dependencies as $dependency ) {
1389 if ( isset( $dependency['p_id'] ) && (string) $dependency['p_id'] === $want ) {
1390 $have = true;
1391 break;
1392 }
1393 }
1394
1395 if ( ! $have ) {
1396 $entry = Wdkit_Import_Dependencies::builder_entry( $catalogue, $want );
1397
1398 if ( ! empty( $entry ) ) {
1399 $dependencies[] = $entry;
1400 }
1401 }
1402 }
1403
1404 /* Nexter Extension, when the kit ships theme-builder templates. `nxt_builder` is that
1405 * plugin's post type - without it those templates exist as rows nothing renders. The
1406 * browser applies the same rule (`if ("nxt_builder" == wp_post_type) push(extension_obj)`
1407 * in template_card.js); here it is derived from the kit's own template list rather than
1408 * taken from the caller, so it needs no trust. Values match that object. */
1409 $needs_extension = false;
1410
1411 foreach ( $templates as $template ) {
1412 if ( is_array( $template ) && isset( $template['wp_post_type'] ) && 'nxt_builder' === $template['wp_post_type'] ) {
1413 $needs_extension = true;
1414 break;
1415 }
1416 }
1417
1418 if ( $needs_extension ) {
1419 $have_extension = false;
1420
1421 foreach ( $dependencies as $dependency ) {
1422 if ( isset( $dependency['original_slug'] ) && 'nexter-extension' === $dependency['original_slug'] ) {
1423 $have_extension = true;
1424 break;
1425 }
1426 }
1427
1428 if ( ! $have_extension ) {
1429 $dependencies[] = array(
1430 'p_id' => 2004,
1431 'plugin_name' => 'Nexter Extension – Site Enhancements Toolkit',
1432 'plugin_slug' => 'nexter-extension/nexter-extension.php',
1433 'original_slug' => 'nexter-extension',
1434 'freepro' => '0',
1435 'type' => 'plugin',
1436 );
1437 }
1438 }
1439
1440 /* Nexter Blocks, when this run will import blog posts.
1441 *
1442 * The posts do not come from the site's own kit: Wdkit_Import_Posts falls back to a
1443 * FIXED Gutenberg post kit (POST_KIT_ID) whose templates are `tpgb/*` blocks,
1444 * whatever builder the chosen kit uses. Nothing else pulls that plugin in, so on an
1445 * Elementor kit every imported post rendered as raw fallback markup - centred text,
1446 * bare list bullets, and the post kit's own demo copy still showing where a block
1447 * should have rendered the AI's (ClickUp 14ynqxyykcn).
1448 *
1449 * Gated on the same two conditions the post import itself applies, so a run that
1450 * will not create posts does not install a plugin for them: the blog_post answer,
1451 * and the "site already has posts" limit. */
1452 $wants_posts = ! empty( $this->context['blog_post'] )
1453 && class_exists( 'Wdkit_Import_Posts' )
1454 && count( get_posts( array( 'post_type' => 'post', 'post_status' => 'any', 'numberposts' => Wdkit_Import_Posts::EXISTING_POST_LIMIT + 1, 'fields' => 'ids' ) ) ) <= Wdkit_Import_Posts::EXISTING_POST_LIMIT;
1455
1456 if ( $wants_posts ) {
1457 $have_blocks = false;
1458
1459 foreach ( $dependencies as $dependency ) {
1460 if ( isset( $dependency['original_slug'] ) && 'the-plus-addons-for-block-editor' === $dependency['original_slug'] ) {
1461 $have_blocks = true;
1462 break;
1463 }
1464 }
1465
1466 if ( ! $have_blocks ) {
1467 /* From the catalogue when it lists it, so the p_id stays whatever the cloud
1468 * says; the fixed descriptor below is the fallback, and free plugins install
1469 * from their slug anyway. */
1470 $entry = null;
1471
1472 foreach ( (array) $catalogue as $candidate ) {
1473 if ( is_array( $candidate ) && isset( $candidate['original_slug'] ) && 'the-plus-addons-for-block-editor' === $candidate['original_slug'] ) {
1474 $entry = $candidate;
1475 break;
1476 }
1477 }
1478
1479 $dependencies[] = ( null !== $entry ) ? $entry : array(
1480 'p_id' => 1002,
1481 'plugin_name' => 'Nexter Blocks',
1482 'plugin_slug' => 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php',
1483 'original_slug' => 'the-plus-addons-for-block-editor',
1484 'freepro' => '0',
1485 'type' => 'plugin',
1486 );
1487 }
1488 }
1489
1490 /* The wizard's feature switches - eCommerce, Dynamic Content, Performance,
1491 * Security, Extras. The browser path pushes these onto the same install list
1492 * (`site_obj.plugin_setting` -> props.new_plugins in import_loader.js); the runner
1493 * never did, so a visitor who switched on Security and Performance watched the
1494 * progress list install the kit's own four dependencies and nothing else
1495 * (ClickUp 14ynqxyykan).
1496 *
1497 * The descriptors are fixed here, exactly like the theme and the builder above: the
1498 * context carries switch NAMES from a closed list, never a slug, so a payload can
1499 * ask for "security" but cannot name a package. Values match new_plugins in
1500 * import_temp_main.js. */
1501 $feature_plugins = array(
1502 'ecommerce' => array(
1503 'p_id' => 1012,
1504 'plugin_name' => 'WooCommerce',
1505 'plugin_slug' => 'woocommerce/woocommerce.php',
1506 'original_slug' => 'woocommerce',
1507 'freepro' => '0',
1508 'type' => 'plugin',
1509 ),
1510 'dynamic_content' => array(
1511 'p_id' => 2001,
1512 'plugin_name' => 'Advanced Custom Fields',
1513 'plugin_slug' => 'advanced-custom-fields/acf.php',
1514 'original_slug' => 'advanced-custom-fields',
1515 'freepro' => '0',
1516 'type' => 'plugin',
1517 ),
1518 'performance' => array(
1519 'p_id' => 2003,
1520 'plugin_name' => 'WP-Optimize - Clean, Compress, Cache',
1521 'plugin_slug' => 'wp-optimize/wp-optimize.php',
1522 'original_slug' => 'wp-optimize',
1523 'freepro' => '0',
1524 'type' => 'plugin',
1525 ),
1526 'security' => array(
1527 'p_id' => 2002,
1528 'plugin_name' => 'All-In-One Security (AIOS)',
1529 'plugin_slug' => 'all-in-one-wp-security-and-firewall/wp-security.php',
1530 'original_slug' => 'all-in-one-wp-security-and-firewall',
1531 'freepro' => '0',
1532 'type' => 'plugin',
1533 ),
1534 'extras' => array(
1535 'p_id' => 2005,
1536 'plugin_name' => 'Rank Math SEO',
1537 'plugin_slug' => 'seo-by-rank-math/rank-math.php',
1538 'original_slug' => 'seo-by-rank-math',
1539 'freepro' => '0',
1540 'type' => 'plugin',
1541 ),
1542 );
1543
1544 $features = ( ! empty( $this->context['features'] ) && is_array( $this->context['features'] ) )
1545 ? $this->context['features']
1546 : array();
1547
1548 foreach ( $features as $feature ) {
1549 if ( ! isset( $feature_plugins[ $feature ] ) ) {
1550 continue;
1551 }
1552
1553 $slug = $feature_plugins[ $feature ]['original_slug'];
1554 $have = false;
1555
1556 foreach ( $dependencies as $dependency ) {
1557 if ( isset( $dependency['original_slug'] ) && $slug === $dependency['original_slug'] ) {
1558 $have = true;
1559 break;
1560 }
1561 }
1562
1563 if ( ! $have ) {
1564 $dependencies[] = $feature_plugins[ $feature ];
1565 }
1566 }
1567
1568 /**
1569 * Filter the resolved dependency list for a PHP-run import.
1570 *
1571 * Runs after resolution, so a filter can remove or reorder but the list still
1572 * originates from the kit's own metadata.
1573 *
1574 * @param array[] $dependencies Resolved dependencies.
1575 * @param string $kit_id Kit id.
1576 * @param array $context Validated context.
1577 */
1578 $dependencies = apply_filters( 'wdkit_import_runner_dependencies', $dependencies, $this->context['kit_id'], $this->context );
1579
1580 return is_array( $dependencies ) ? $dependencies : array();
1581 }
1582
1583 /**
1584 * The plugin catalogue the kit's `plugins_id` values resolve against.
1585 *
1586 * Carried on the context by whoever built the session — the same list the browser gets
1587 * as `wdkit_meta.plugin`. Exposed as a filter so a caller can supply it without the
1588 * runner making its own cloud call mid-import.
1589 *
1590 * @return array
1591 */
1592 private function plugin_catalogue() {
1593 $catalogue = ! empty( $this->context['plugin_catalogue'] ) ? $this->context['plugin_catalogue'] : array();
1594
1595 /* Nobody supplied one, which is every caller except the wizard. Fetch it, because
1596 * an empty catalogue means resolve() cannot look up a single `plugins_id` and the
1597 * kit's own addons are silently never installed. */
1598 if ( empty( $catalogue ) && class_exists( 'Wdkit_Import_Dependencies' ) ) {
1599 $catalogue = Wdkit_Import_Dependencies::catalogue();
1600 }
1601
1602 /**
1603 * Filter the plugin catalogue used for dependency resolution.
1604 *
1605 * @param array $catalogue Catalogue entries.
1606 * @param array $context Validated context.
1607 */
1608 $catalogue = apply_filters( 'wdkit_import_runner_plugin_catalogue', $catalogue, $this->context );
1609
1610 return is_array( $catalogue ) ? $catalogue : array();
1611 }
1612
1613 /*
1614 |--------------------------------------------------------------------------
1615 | Stage 2 — content
1616 |--------------------------------------------------------------------------
1617 */
1618
1619 /**
1620 * Fetch, prepare and insert every template in the kit.
1621 *
1622 * Each template is independent: one that fails is recorded and skipped so a single bad
1623 * page cannot cost the whole run.
1624 *
1625 * @return array
1626 */
1627 private function stage_content() {
1628 $templates = ! empty( $this->context['templates'] ) ? $this->context['templates'] : array();
1629
1630 /* Taxonomy first: a post template that carries categories needs its terms to exist
1631 * before the post is inserted, which is the order the browser uses too
1632 * (import_post_taxonomy runs before import_post_json). Idempotent, so a resume
1633 * simply re-resolves the same ids. */
1634 $taxonomy = array();
1635
1636 if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'taxonomy' ) ) {
1637 $names = Wdkit_Import_Taxonomy::collect( $this->kit_taxonomy_source() );
1638 $taxonomy = Wdkit_Import_Taxonomy::ensure( $names['categories'], $names['tags'] );
1639
1640 Wdkit_Import_Session::mark_step_complete( $this->session_id, 'taxonomy', $taxonomy );
1641 }
1642
1643 /* The whole kit in one cloud round trip, before the first template is touched.
1644 * Idempotent and best-effort: on a miss every template falls back to its own
1645 * `import_template` request, which is what this stage used to do for all of them. */
1646 $builder_name = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor';
1647
1648 Wdkit_Page_Importer::prime_bundle( $this->context, $builder_name, $templates );
1649
1650 /* Post templates are pulled out and handled by Wdkit_Import_Posts, which needs to
1651 * resolve taxonomy names to ids and pick a featured image before the insert. They
1652 * still go through this same Wdkit_Page_Importer — see that class for why blog posts
1653 * are not a separate content pipeline. */
1654 $post_templates = Wdkit_Import_Posts::post_templates( $templates );
1655 $post_ids = array();
1656
1657 foreach ( $post_templates as $post_template ) {
1658 $post_ids[ (string) $post_template['id'] ] = true;
1659 }
1660
1661 /* Every page listed before anything is imported, as the plugin wizard does - and before
1662 * the AI batch below, which can take most of this stage on its own. */
1663 $this->report_page_progress( $templates, $post_ids, array(), array() );
1664
1665 // Make sure all required widgets are enabled and registered before templates are imported and saved.
1666 $this->prime_and_enable_widgets();
1667
1668 /* Every template's AI copy in one batched cloud round trip, instead of one
1669 * `ai/template_import` call per template inside the foreach below. This is what
1670 * turns 14-19 sequential 2-55s calls into a single request bounded by the slowest
1671 * page — the sequential version was the bulk of a 2-3 minute import. Idempotent and
1672 * best-effort per prime_ai_batch()'s own contract: a miss just leaves that one
1673 * template on its original per-template call. The session id lets it skip any
1674 * template a previous slice already got AI copy for — see its own docblock for why
1675 * that is not optional on a resumable, multi-request run.
1676 *
1677 * Claimed, not called unconditionally: the browser opens RUNNER_LANES requests in
1678 * the same tick, and self::$ai_batch_cache is a per-PROCESS static — nothing tells
1679 * a sibling lane this one is already three seconds into the same 25-50s call, so
1680 * without a claim every lane placed its own identical request for the same 14
1681 * pages, in parallel, each paying the full network cost (measured: two lanes' own
1682 * ai_batch log lines a second apart, both 50s+, for the same kit the first lane
1683 * had already answered). A lane that loses the claim leaves its own cache empty
1684 * for this slice — exactly the "batch never ran" case prime_ai_batch()'s own
1685 * callers already handle by falling back to the normal out_of_time()-gated
1686 * per-template path below — and picks up whatever the claiming lane saved
1687 * (has_payload()) on its next poll, instead of duplicating the fetch. */
1688 if ( Wdkit_Import_Session::claim( $this->session_id, 'ai_batch_prime' ) ) {
1689 try {
1690 Wdkit_Page_Importer::prime_ai_batch( $this->context, $builder_name, $templates, $this->session_id );
1691 } finally {
1692 Wdkit_Import_Session::release( $this->session_id, 'ai_batch_prime' );
1693 }
1694 }
1695
1696 $this->remember_kit_site_settings();
1697
1698 /* Palette, plugin and theme settings first — latest's order, and the order the pages
1699 * actually need. See settings_before_content(). */
1700 $this->settings_before_content(
1701 ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor'
1702 );
1703
1704 $importer = new Wdkit_Page_Importer( $this->context, $this->session_id );
1705
1706 $pages = array();
1707 $failed = array();
1708
1709
1710 $pending = 0;
1711 $taken = 0;
1712
1713 /* Pages imported in THIS request, as opposed to $pages which accumulates everything
1714 * completed so far. The browser runs its global-reference rewrite over this list, and
1715 * that transform is NOT idempotent — re-running it on an already-rewritten page
1716 * strips it further each time (observed: 227KB -> 212KB -> 207KB -> 205KB across
1717 * slices). It must therefore see each page exactly once. */
1718 $created = array();
1719
1720 foreach ( $templates as $template ) {
1721 if ( isset( $post_ids[ (string) $template['id'] ] ) ) {
1722 continue;
1723 }
1724
1725 /* Already tried in this process and it did not land — leave it for an explicit
1726 * retry rather than re-attempting it now and starving the templates after it. */
1727 if ( isset( $this->attempted[ (string) $template['id'] ] ) ) {
1728 continue;
1729 }
1730
1731 /* Out of retries across the whole run, not just this process. Counting it as
1732 * pending here would ask the browser to call the stage again for a step that can
1733 * only fail the same way, which is the loop that reached 907 attempts. Skipping it
1734 * instead lets the stage settle so the failure reaches the screen. */
1735 if ( Wdkit_Import_Session::is_step_exhausted( $this->session_id, 'page_' . $template['id'] ) ) {
1736 continue;
1737 }
1738
1739 /* Slice spent — count what is left and hand control back. Checked between
1740 * templates, never inside one, so a template is always all-or-nothing.
1741 *
1742 * A template prime_ai_batch() already has an answer for gets FREE_ANSWER_BUDGET
1743 * instead of STAGE_BUDGET: that page costs this request a merge and a DB write,
1744 * not the network wait out_of_time() exists to bound, so gating it identically
1745 * to a page that still needs a fresh cloud call was the actual cause of a real
1746 * stall — the batch call alone can run past the whole stage budget (bounded by
1747 * its slowest page, by design), so every page reported pending before any of
1748 * them were imported, nothing was ever saved, and the identical batch call
1749 * repeated forever. See has_batched_answer()'s own docblock.
1750 *
1751 * It is not UNBOUNDED, though — that was this fix's own first pass, and it let
1752 * one stubborn template push a single request to 82s, past what a browser or
1753 * reverse proxy will wait for before showing a 504 (the import kept running
1754 * server-side and finished anyway, but the wizard had already shown an error).
1755 * out_of_free_time() gives free templates their own, separate, still-bounded
1756 * slice instead of no limit at all. */
1757 $has_free_answer = Wdkit_Page_Importer::has_batched_answer( $template['id'], $this->session_id );
1758
1759 if ( $has_free_answer ? $this->out_of_free_time() : ( $this->out_of_time() || $taken >= self::STAGE_ITEM_CAP ) ) {
1760 if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'page_' . $template['id'] ) ) {
1761 ++$pending;
1762 }
1763
1764 continue;
1765 }
1766
1767 $step = 'page_' . $template['id'];
1768
1769 /* Already imported on a previous attempt. This is the idempotency guarantee:
1770 * a retry after page 6 fails must not re-import — or re-charge for — pages 1-5. */
1771 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
1772 $session = Wdkit_Import_Session::get( $this->session_id );
1773
1774 if ( ! empty( $session['results'][ $step ] ) ) {
1775 $pages[] = $session['results'][ $step ];
1776 }
1777
1778 continue;
1779 }
1780
1781 /* Another concurrent request is already on this template. Count it as outstanding so
1782 * this lane keeps asking, but do not import it a second time. */
1783 if ( ! Wdkit_Import_Session::claim( $this->session_id, $step ) ) {
1784 ++$pending;
1785
1786 continue;
1787 }
1788
1789 /* The is_step_complete() check above and this claim are not one atomic step:
1790 * another lane can complete the template in the gap, and a stale-claim takeover
1791 * can hand this lane a step a now-gone lane finished cleanly but never released.
1792 * Re-check on the step's own row before paying to import it again. */
1793 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
1794 Wdkit_Import_Session::release( $this->session_id, $step );
1795
1796 $session = Wdkit_Import_Session::get( $this->session_id );
1797
1798 if ( ! empty( $session['results'][ $step ] ) ) {
1799 $pages[] = $session['results'][ $step ];
1800 }
1801
1802 continue;
1803 }
1804
1805 ++$taken;
1806
1807 $this->attempted[ (string) $template['id'] ] = true;
1808
1809 try {
1810 $page = $importer->import( $template );
1811
1812 $pages[] = $page;
1813 $created[] = $page;
1814
1815 Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $page );
1816 } catch ( Wdkit_Import_Exception $e ) {
1817 $record = Wdkit_Import_Errors::to_record( $e, $step );
1818
1819 if ( $e->is_skippable() ) {
1820 /* This one template does not exist on the site and never will - not a
1821 * transient failure to retry, not a reason to stall the step or show an
1822 * error. Marking it complete (not failed) is what stops it being retried
1823 * on every later slice; leaving it out of $pages/$created/$failed is what
1824 * keeps a page nothing was written for out of the nav menu, front-page
1825 * detection and media sweep, which all key off a real post id. This is
1826 * the runner's version of the browser importer writing nothing for the
1827 * same case (class-api.php:8911 / :9071) - both end with the kit's other
1828 * templates imported and this one simply absent. */
1829 Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $record );
1830 } else {
1831 $failed[] = $record;
1832
1833 Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, $record );
1834 }
1835 } catch ( Throwable $e ) {
1836 $record = Wdkit_Import_Errors::to_record( $e, $step );
1837
1838 $failed[] = $record;
1839
1840 Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, $record );
1841 }
1842
1843 /* Held only for the duration of the import itself. A completed step is protected by
1844 * is_step_complete() from here on, and a failed one must be free for the retry. */
1845 Wdkit_Import_Session::release( $this->session_id, $step );
1846
1847 $this->report_page_progress( $templates, $post_ids, $pages, $failed );
1848 }
1849
1850 /* Deferred media is NOT queued from here. Each page's still-remote URLs travel in its
1851 * own step result (`image_urls`, recorded by Wdkit_Page_Importer::import()), and
1852 * stage_finalize queues them all at once - see schedule_media_sweep(). */
1853
1854 $document = ! empty( $this->context['ai_document'] ) ? $this->context['ai_document'] : array();
1855
1856 /* Blog posts after pages: a post's featured image comes from the same pool the page
1857 * images were drawn from, and the reuse penalty only spreads sensibly once the pages
1858 * have had their pick. Each post is its own resumable step. */
1859 /* Posts and products only once every page has landed: they are cheaper, and running
1860 * them now would spend the slice the remaining pages need. */
1861 if ( $pending > 0 ) {
1862 return array(
1863 'pages' => $pages,
1864 'created' => $created,
1865 'failed' => $failed,
1866 'taxonomy' => $taxonomy,
1867 'pending' => $pending,
1868 );
1869 }
1870
1871 $posts = Wdkit_Import_Posts::import( $post_templates, $this->context, $this->session_id, $document );
1872
1873 /* Products last: they can reference categories created above, and a shop page
1874 * imported as a template needs to exist before the shop option is set in stage 3. */
1875 /* A website / sandbox job carries no AI products (the plugin's wizard generates them
1876 * in the browser), so ask for them here rather than ship the demo catalogue. */
1877 if ( ! empty( $this->context['ecommerce'] )
1878 && 'ai_import' === ( isset( $this->context['import_type'] ) ? $this->context['import_type'] : '' )
1879 && empty( $this->context['products'] )
1880 && empty( $document['products'] )
1881 ) {
1882 $document['products'] = Wdkit_Import_Products::generate_ai_products( $this->context, $this->session_id );
1883 }
1884
1885 $products = Wdkit_Import_Products::import(
1886 Wdkit_Import_Products::apply_ai_document(
1887 ! empty( $this->context['products'] ) ? $this->context['products'] : array(),
1888 $document
1889 ),
1890 isset( $this->context['kit_id'] ) ? $this->context['kit_id'] : '',
1891 $this->session_id,
1892 array(
1893 'update_existing' => ! empty( $this->context['update_existing_products'] ),
1894
1895 /* Only a shop import falls back to the demo catalogue — see default_products().
1896 * A remote caller that passes its own `products` never reaches the fallback
1897 * anyway, so setting this costs it nothing. */
1898 'use_defaults' => ! empty( $this->context['ecommerce'] ),
1899 )
1900 );
1901
1902 /* Several lanes reach the posts/products tail in the same slice once every page has
1903 * landed. Each post/product now takes its own claim (see those loops), so a lane
1904 * that finds an item already claimed skips it and reports it as still outstanding —
1905 * the same contract $pending gives the page loop, so the stage stays OPEN until the
1906 * lane that owns each item has finished it. Without this the first lane to return
1907 * would mark the stage complete while a sibling was still mid-post. */
1908 $tail_pending = (int) ( isset( $posts['incomplete'] ) ? $posts['incomplete'] : 0 )
1909 + (int) ( isset( $products['incomplete'] ) ? $products['incomplete'] : 0 );
1910
1911 if ( $tail_pending > 0 ) {
1912 return array(
1913 'pages' => $pages,
1914 'created' => $created,
1915 'failed' => $failed,
1916 'taxonomy' => $taxonomy,
1917 'posts' => $posts,
1918 'products' => $products,
1919 'pending' => $tail_pending,
1920 );
1921 }
1922
1923 return array(
1924 'pages' => $pages,
1925 'created' => $created,
1926 'failed' => $failed,
1927 'taxonomy' => $taxonomy,
1928 'posts' => $posts,
1929 'products' => $products,
1930 );
1931 }
1932
1933 /**
1934 * Tell stage listeners how far the page list has got, after each page.
1935 *
1936 * The whole content stage runs in one request on a website-queued job, so its only
1937 * report used to come when every page was done - the website's build screen showed an
1938 * empty "Importing pages" step and then a finished list. The plugin wizard lists every
1939 * page up front and ticks them off as they land; this carries the same information:
1940 * the pages done so far, and the titles still to come.
1941 *
1942 * @param array $templates All templates for the run.
1943 * @param array $post_ids Template ids imported as blog posts, keyed by id.
1944 * @param array $pages Page records imported so far.
1945 * @param array $failed Failure records so far.
1946 * @return void
1947 */
1948 private function report_page_progress( $templates, $post_ids, $pages, $failed ) {
1949 $handled = array();
1950
1951 foreach ( array_merge( (array) $pages, (array) $failed ) as $record ) {
1952 if ( ! is_array( $record ) ) {
1953 continue;
1954 }
1955
1956 if ( ! empty( $record['template_id'] ) ) {
1957 $handled[ (string) $record['template_id'] ] = true;
1958 } elseif ( ! empty( $record['step'] ) && 0 === strpos( (string) $record['step'], 'page_' ) ) {
1959 $handled[ substr( (string) $record['step'], 5 ) ] = true;
1960 }
1961 }
1962
1963 $upcoming = array();
1964
1965 foreach ( (array) $templates as $template ) {
1966 $id = isset( $template['id'] ) ? (string) $template['id'] : '';
1967
1968 if ( '' === $id || isset( $post_ids[ $id ] ) || isset( $handled[ $id ] )
1969 || Wdkit_Import_Session::is_step_complete( $this->session_id, 'page_' . $id ) ) {
1970 continue;
1971 }
1972
1973 $upcoming[] = isset( $template['title'] ) ? trim( explode( '|', (string) $template['title'] )[0] ) : $id;
1974 }
1975
1976 do_action(
1977 'wdkit_import_runner_stage',
1978 self::STAGE_CONTENT,
1979 'progress',
1980 array(
1981 'pages' => $pages,
1982 'failed' => $failed,
1983 'pending' => count( $upcoming ),
1984 'upcoming' => $upcoming,
1985 ),
1986 $this->session_id
1987 );
1988 }
1989
1990 /**
1991 * Hand every imported page's still-remote media to the background sweep, in one go.
1992 *
1993 * This used to run once per content slice, and the content stage runs RUNNER_LANES
1994 * requests in parallel. wp_schedule_single_event() is a read-modify-write of the single
1995 * `cron` option, so a sibling lane that had read that option a moment earlier - to queue
1996 * its own thumbnail regeneration, say - wrote it back without the event just added.
1997 * Measured on a Taj Bakery import: About Us was queued with its tp-video-player's mp4
1998 * (`media_sweep scheduled:1` in the debug log), the event was gone from the cron array
1999 * without ever running, and the video stayed on etemplates.wdesignkit.com with a
2000 * blanked id. The browser importer never met this because it queues everything from one
2001 * request at the end of the run - which is what this now does too.
2002 *
2003 * Called from stage_finalize after the navigation and demo-link rewrites, the last
2004 * writers of page content, so a cron tick that sideloads straight away cannot have its
2005 * rewrite overwritten by a stale read-modify-write from those steps. Only URLs still in
2006 * the stored content are queued: the stage-3 attachment sweep may already have
2007 * localised some, and handing those over again would download them a second time.
2008 *
2009 * Best-effort by design. A page whose media could not be queued renders from the source
2010 * CDN, which is what it did before deferral existed - so a failure here costs a slower
2011 * first paint, never a broken page.
2012 *
2013 * @since 2.7.2
2014 *
2015 * @param array $records Page step results for the whole run (page_records()).
2016 * @return array { scheduled: int, pages: array[], order: int[] } - `pages` and `order`
2017 * are what the wizard's success screen drains itself.
2018 */
2019 private function schedule_media_sweep( $records ) {
2020 $none = array( 'scheduled' => 0, 'pages' => array(), 'order' => array() );
2021
2022 if ( empty( $records ) || ! is_array( $records ) ) {
2023 return $none;
2024 }
2025
2026 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_schedule_media_sync_for' ) ) {
2027 return $none;
2028 }
2029
2030 $builder = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor';
2031 $pages = array();
2032
2033 foreach ( $records as $page ) {
2034 if ( empty( $page['post_id'] ) || empty( $page['image_urls'] ) || ! is_array( $page['image_urls'] ) ) {
2035 continue;
2036 }
2037
2038 $post_id = (int) $page['post_id'];
2039 $stored = str_replace( '\\/', '/', (string) get_post_meta( $post_id, '_elementor_data', true ) )
2040 . (string) get_post_field( 'post_content', $post_id );
2041
2042 $remote = array();
2043
2044 foreach ( $page['image_urls'] as $url ) {
2045 if ( is_string( $url ) && '' !== $url
2046 && ( false !== strpos( $stored, $url ) || false !== strpos( $stored, str_replace( '&', '&#038;', $url ) ) ) ) {
2047 $remote[] = $url;
2048 }
2049 }
2050
2051 if ( empty( $remote ) ) {
2052 continue;
2053 }
2054
2055 $pages[] = array(
2056 'post_id' => $post_id,
2057 'builder' => $builder,
2058 'image_urls' => array_values( array_unique( $remote ) ),
2059 );
2060 }
2061
2062 if ( empty( $pages ) ) {
2063 return $none;
2064 }
2065
2066 $result = Wdkit_Api_Call::get_instance()->wdkit_schedule_media_sync_for( $pages );
2067
2068 $scheduled = ( is_array( $result ) && isset( $result['scheduled'] ) ) ? (int) $result['scheduled'] : 0;
2069
2070 Wdkit_Import_Log::add(
2071 'media_sweep',
2072 array(
2073 'pages' => count( $pages ),
2074 'scheduled' => $scheduled,
2075 )
2076 );
2077
2078 return array(
2079 'scheduled' => $scheduled,
2080 'pages' => $scheduled ? $pages : array(),
2081 'order' => ( is_array( $result ) && ! empty( $result['order'] ) ) ? array_map( 'intval', (array) $result['order'] ) : array(),
2082 );
2083 }
2084
2085 /**
2086 * Where taxonomy names come from.
2087 *
2088 * The kit's own template payloads carry them; a context may also supply them for a
2089 * post-only import. Both are name lists, and Wdkit_Import_Taxonomy re-cleans whatever
2090 * arrives, so this only has to gather.
2091 *
2092 * @return array[]
2093 */
2094 private function kit_taxonomy_source() {
2095 $sources = array();
2096
2097 foreach ( $this->page_records() as $page ) {
2098 if ( ! empty( $page['categories'] ) || ! empty( $page['tags'] ) ) {
2099 $sources[] = $page;
2100 }
2101 }
2102
2103 if ( ! empty( $this->context['categories'] ) || ! empty( $this->context['tags'] ) ) {
2104 $sources[] = array(
2105 'categories' => ! empty( $this->context['categories'] ) ? $this->context['categories'] : array(),
2106 'tags' => ! empty( $this->context['tags'] ) ? $this->context['tags'] : array(),
2107 );
2108 }
2109
2110 /* And the AI document's own taxonomy list. Wdkit_Import_Posts ensures these again
2111 * before it inserts — deliberately, since it needs the resolved ids — but having
2112 * them here means a kit with no post templates still gets its terms. */
2113 if ( ! empty( $this->context['ai_document']['taxonomy'] ) ) {
2114 $sources[] = $this->context['ai_document']['taxonomy'];
2115 }
2116
2117 return $sources;
2118 }
2119
2120 /*
2121 |--------------------------------------------------------------------------
2122 | Stage 3 — site setup
2123 |--------------------------------------------------------------------------
2124 */
2125
2126 /**
2127 * Apply the site identity the context carries.
2128 *
2129 * Only ever writes `blogname` and `blogdescription`. A context cannot reach any other
2130 * option — that restriction is the point, not an oversight.
2131 *
2132 * @return array
2133 */
2134 private function stage_setup() {
2135 $builder = ! empty( $this->context['builder'] ) ? $this->context['builder'] : 'elementor';
2136 $pages = $this->page_records();
2137
2138 /* Order matters and mirrors the browser: plugin settings before globals (Elementor's
2139 * container experiment has to be on before the kit CSS is regenerated), then
2140 * globals, then theme, then the site options that point the front page at an
2141 * imported page, then widgets. */
2142 /* ── This stage is sliced, for the same reason stage 2 is ──────────────────
2143 *
2144 * These five sub-steps used to run in a single request with no time budget. On a
2145 * live host that is a 504 waiting to happen, and it did: a 19-template Elementor kit
2146 * imported all of its content and then died here with
2147 * "admin-ajax.php 504", because Elementor's files_manager->clear_cache() and the
2148 * theme-builder remap both grow with the kit and the gateway gave up first.
2149 *
2150 * A 504 kills the process outright - no shutdown handler, no error record - so the
2151 * browser could not even say what went wrong. Each sub-step is already memoised by
2152 * once(), so all that was missing was permission to stop between them and be called
2153 * again. `remaining` is reported as `pending`, which is the signal the browser
2154 * already acts on for the content stage. */
2155 $order = array( 'plugin_settings', 'globals', 'theme_settings', 'site_settings', 'widgets' );
2156 $remaining = 0;
2157
2158 foreach ( $order as $step ) {
2159 if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
2160 ++$remaining;
2161 }
2162 }
2163
2164 $out = array(
2165 'plugin_settings' => null,
2166 'globals' => null,
2167 'theme_settings' => null,
2168 'site_settings' => null,
2169 'widgets' => null,
2170 );
2171
2172 $plugin = $this->once(
2173 'plugin_settings',
2174 function () use ( $builder ) {
2175 return $this->step_plugin_settings( $builder );
2176 }
2177 );
2178
2179 if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'globals' ) ) {
2180 return $this->setup_slice( $out, array( 'plugin_settings' => $plugin ), $order );
2181 }
2182
2183 $globals = $this->once(
2184 'globals',
2185 function () use ( $builder ) {
2186 return $this->step_globals( $builder );
2187 }
2188 );
2189
2190 if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'theme_settings' ) ) {
2191 return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals ), $order );
2192 }
2193
2194 $theme = $this->once( 'theme_settings', array( 'Wdkit_Import_Settings', 'apply_theme' ) );
2195
2196 if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'site_settings' ) ) {
2197 return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals, 'theme_settings' => $theme ), $order );
2198 }
2199
2200 $site = $this->once(
2201 'site_settings',
2202 function () use ( $pages ) {
2203 return Wdkit_Import_Settings::apply_site( $this->context, array(), $pages );
2204 }
2205 );
2206
2207 if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'widgets' ) ) {
2208 return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals, 'theme_settings' => $theme, 'site_settings' => $site ), $order );
2209 }
2210
2211 $widgets = $this->once( 'widgets', array( $this, 'enable_kit_widgets' ) );
2212
2213 return array(
2214 'plugin_settings' => $plugin,
2215 'globals' => $globals,
2216 'theme_settings' => $theme,
2217 'site_settings' => $site,
2218 'widgets' => $widgets,
2219 );
2220 }
2221
2222 /**
2223 * A partial setting_up_site result: what this request finished, and how much is left.
2224 *
2225 * `pending` is what makes the browser call the stage again, and every finished sub-step is
2226 * recorded by once(), so the next request resumes rather than repeats.
2227 *
2228 * @param array $shape Full result shape with null placeholders.
2229 * @param array $done Sub-step results this request produced.
2230 * @param string[] $order Sub-step names, in order.
2231 * @return array
2232 */
2233 private function setup_slice( $shape, $done, $order ) {
2234 $left = 0;
2235
2236 foreach ( $order as $step ) {
2237 if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
2238 ++$left;
2239 }
2240 }
2241
2242 if ( class_exists( 'Wdkit_Import_Log' ) ) {
2243 Wdkit_Import_Log::add(
2244 'setup_slice',
2245 array(
2246 'completed' => implode( ',', array_keys( $done ) ),
2247 'pending' => $left,
2248 )
2249 );
2250 }
2251
2252 return array_merge( $shape, $done, array( 'pending' => $left ) );
2253 }
2254
2255 /**
2256 * Per-page results recorded during the content stage.
2257 *
2258 * @return array[]
2259 */
2260 private function page_records() {
2261 $session = Wdkit_Import_Session::get( $this->session_id );
2262
2263 if ( null === $session || empty( $session['results'] ) ) {
2264 return array();
2265 }
2266
2267 $pages = array();
2268
2269 foreach ( $session['results'] as $step => $result ) {
2270 if ( 0 === strpos( (string) $step, 'page_' ) && is_array( $result ) ) {
2271 $pages[] = $result;
2272 }
2273 }
2274
2275 return $pages;
2276 }
2277
2278 /**
2279 * Ids of the blog posts this run created (or adopted on a resume).
2280 *
2281 * @return int[]
2282 */
2283 private function blog_post_ids() {
2284 $session = Wdkit_Import_Session::get( $this->session_id );
2285
2286 if ( null === $session || empty( $session['results'] ) ) {
2287 return array();
2288 }
2289
2290 $ids = array();
2291
2292 foreach ( $session['results'] as $step => $result ) {
2293 if ( 0 === strpos( (string) $step, 'post_' ) && is_array( $result ) && ! empty( $result['post_id'] ) ) {
2294 $ids[] = (int) $result['post_id'];
2295 }
2296 }
2297
2298 return $ids;
2299 }
2300
2301 /**
2302 * Decoded template payloads kept from the content stage, for globals collection.
2303 *
2304 * @return array[]
2305 */
2306 private function decoded_templates() {
2307 $decoded = array();
2308
2309 foreach ( $this->page_records() as $page ) {
2310 if ( ! empty( $page['global_data'] ) ) {
2311 $decoded[] = array( 'global_data' => $page['global_data'] );
2312 }
2313 }
2314
2315 return $decoded;
2316 }
2317
2318 /**
2319 * Enable the widgets and extensions the imported content actually uses.
2320 *
2321 * The list is collected from the decoded templates recorded during the content stage —
2322 * derived from kit content, never accepted as input. Reuses the extracted
2323 * `enable_template_widgets` logic.
2324 *
2325 * @return array
2326 */
2327 public function enable_kit_widgets() {
2328 $session = Wdkit_Import_Session::get( $this->session_id );
2329
2330 if ( null === $session || empty( $session['results'] ) ) {
2331 return array( 'enabled' => false );
2332 }
2333
2334 $widget_lists = array();
2335 $block_names = array();
2336 $elementor_ids = array();
2337
2338 foreach ( $session['results'] as $step => $result ) {
2339 if ( 0 !== strpos( (string) $step, 'page_' ) || ! is_array( $result ) ) {
2340 continue;
2341 }
2342
2343 if ( ! empty( $result['widget_list'] ) ) {
2344 $widget_lists[] = array( 'widget_list' => $result['widget_list'] );
2345 }
2346
2347 /* Gutenberg blocks are named in the markup, not in `widget_list` - that field and
2348 * `widgetType` are Elementor's. Read them back off the post that was just created,
2349 * which is also what makes this correct on a resume: a request that imported no
2350 * pages still sees every page the run created. */
2351 if ( 'gutenberg' === $this->context['builder'] && ! empty( $result['post_id'] ) ) {
2352 $block_names = array_merge(
2353 $block_names,
2354 Wdkit_Import_Dependencies::block_names_in( get_post_field( 'post_content', (int) $result['post_id'] ) )
2355 );
2356 } elseif ( ! empty( $result['post_id'] ) ) {
2357 $elementor_ids[] = (int) $result['post_id'];
2358 }
2359 }
2360
2361 /* The bundle engine does not report a per-page `widget_list`, so the loop above
2362 * usually collects nothing for an Elementor kit. This step is a second pass behind
2363 * stage 1's kit_widgets - anything already registered when Document::save() ran was
2364 * kept, and re-enabling it is idempotent; anything that was NOT is gone from the row
2365 * and cannot be recovered here, which is why the real work happens in stage 1. Read
2366 * the widget types straight off what landed, so a resume that imported nothing this
2367 * request still sees every page. */
2368 if ( empty( $widget_lists ) && ! empty( $elementor_ids ) ) {
2369 $types = array();
2370
2371 foreach ( array_unique( $elementor_ids ) as $pid ) {
2372 $data = get_post_meta( $pid, '_elementor_data', true );
2373 $data = is_string( $data ) ? $data : (string) wp_json_encode( $data );
2374
2375 if ( preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $data, $m ) ) {
2376 $types = array_merge( $types, $m[1] );
2377 }
2378 }
2379
2380 $types = array_values( array_unique( array_filter( $types, 'is_string' ) ) );
2381
2382 if ( ! empty( $types ) ) {
2383 $widget_lists[] = array( 'widget_list' => $types );
2384 }
2385 }
2386
2387 $blocks = array();
2388
2389 if ( ! empty( $block_names ) ) {
2390 $blocks = Wdkit_Import_Dependencies::enable_blocks( array_unique( $block_names ) );
2391 }
2392
2393 if ( empty( $widget_lists ) ) {
2394 return array(
2395 'enabled' => ! empty( $blocks['success'] ),
2396 'blocks' => $blocks,
2397 );
2398 }
2399
2400 $collected = Wdkit_Import_Dependencies::collect_widgets( $widget_lists );
2401
2402 if ( empty( $collected['widgets'] ) && empty( $collected['extensions'] ) ) {
2403 return array(
2404 'enabled' => ! empty( $blocks['success'] ),
2405 'blocks' => $blocks,
2406 );
2407 }
2408
2409 $response = Wdkit_Import_Dependencies::enable_widgets( $collected['widgets'], $collected['extensions'] );
2410
2411 return array(
2412 'enabled' => ! empty( $response['success'] ),
2413 'widgets' => count( $collected['widgets'] ),
2414 'blocks' => $blocks,
2415 );
2416 }
2417
2418 /*
2419 |--------------------------------------------------------------------------
2420 | Stage 4 — finalize
2421 |--------------------------------------------------------------------------
2422 */
2423
2424 /**
2425 * Close the run out and drop the AI payloads.
2426 *
2427 * The payload files are only needed while the merge is running; leaving them behind
2428 * would accumulate template JSON in uploads indefinitely.
2429 *
2430 * @return array
2431 */
2432 /**
2433 * Gives every post with Nexter Blocks css a fresh version, so plus-global.css is re-fetched.
2434 *
2435 * @return int Number of posts updated.
2436 */
2437 public function bump_plus_css_versions() {
2438 global $wpdb;
2439
2440 $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", '_block_css' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2441 $version = time();
2442 $count = 0;
2443
2444 /* A post with no version of its own falls back to this option - and it is what
2445 * Nexter Blocks' own "clear cache" bumps. */
2446 if ( false !== get_option( 'tpgb_backend_cache_at' ) ) {
2447 update_option( 'tpgb_backend_cache_at', $version, false );
2448 }
2449
2450 foreach ( $post_ids as $post_id ) {
2451 $meta = get_post_meta( (int) $post_id, '_block_css', true );
2452 if ( ! is_array( $meta ) ) {
2453 continue;
2454 }
2455 $meta['version'] = $version;
2456 update_post_meta( (int) $post_id, '_block_css', $meta );
2457 ++$count;
2458 }
2459
2460 return $count;
2461 }
2462
2463 private function stage_finalize() {
2464 $builder = ! empty( $this->context['builder'] ) ? $this->context['builder'] : 'elementor';
2465 $pages = $this->page_records();
2466
2467 /* Runs here, not in stage 2: the map is only complete once every page exists, and a
2468 * partial map would leave later pages pointing at nothing. */
2469 $navigation = $this->once(
2470 'navigation',
2471 function () use ( $pages, $builder ) {
2472 return Wdkit_Import_Navigation::rewrite(
2473 Wdkit_Import_Navigation::build_map( $pages ),
2474 $pages,
2475 $builder
2476 );
2477 }
2478 );
2479
2480 /* Same reason and the same moment: the kit's links are absolute urls into the demo
2481 * site it was captured from, and only once every page exists is there a local
2482 * permalink to point each one at. Runs for both builders - the browser's version of
2483 * this is Elementor-shaped and never fired for a gutenberg kit, which is why an
2484 * imported site's whole menu still navigated to gtemplates.wdesignkit.com.
2485 *
2486 * Chunked, unlike `navigation` above: this is one wp_update_post() and one
2487 * get_post_meta()/update_post_meta() pair PER PAGE, with no upper bound - a 50+ page
2488 * kit could run this in one call for as long as PHP lets it, the same shape as the
2489 * already-known prime_ai_batch() P0 (ClickUp 14ynqxyxffw). finalize_demo_links()
2490 * checks out_of_time() between pages and reports back the same way stage_content()
2491 * reports an unfinished page loop - `pending` > 0 keeps this stage OPEN so the
2492 * wizard calls it again, picking up only the pages `demo_link_<id>` has not already
2493 * marked done. */
2494 $links = $this->finalize_demo_links( $pages );
2495
2496 if ( ! empty( $links['pending'] ) ) {
2497 return array(
2498 'navigation' => $navigation,
2499 'demo_links' => $links,
2500 'pending' => $links['pending'],
2501 );
2502 }
2503
2504 /* Nexter Blocks versions the shared plus-global.css by each post's `_block_css`
2505 * version, so a post that existed before this import keeps its old ?ver= and the
2506 * browser serves it the stale global css (ClickUp 14ynqxyykcn). Bumping the version
2507 * only changes that query string - the same thing Nexter Blocks does on post save. */
2508 $this->once( 'plus_css_version', array( $this, 'bump_plus_css_versions' ) );
2509
2510 /* Queue the deferred media now, from this one request, after the two rewrites above -
2511 * the last steps that write page content. See schedule_media_sweep() for why this is
2512 * not done from the parallel content lanes. The pages come back to the wizard, whose
2513 * success screen fetches them straight away instead of waiting for wp-cron. */
2514 $media = $this->once(
2515 'media_sweep',
2516 function () use ( $pages ) {
2517 return $this->schedule_media_sweep( $pages );
2518 }
2519 );
2520
2521 /* Same lost-update exposure for the thumbnail pass the content lanes queue: make sure
2522 * it is still scheduled while any prefetched attachment is waiting on it. */
2523 if ( class_exists( 'Wdkit_Import_Media' ) ) {
2524 Wdkit_Import_Media::ensure_thumbnail_regeneration();
2525 }
2526
2527 /* Declare the global variables the page CSS is about to reference. Every page refers
2528 * to the palette and typography as var(--tpgb-C12) / var(--tpgb-T15-font-size), and
2529 * those live in one shared file; without it the pages are correct and entirely
2530 * unstyled. Written before the per-page CSS and before the front page is warmed, so
2531 * nothing renders against undefined variables.
2532 *
2533 * On the browser path the editor pass that runs after this writes the same file from
2534 * its own generator, which also emits the dark-mode aliases onto the active theme's
2535 * palette. That pass therefore supersedes this one, deliberately: this exists for the
2536 * case where no browser is involved. */
2537 $globals = ( 'gutenberg' === $builder && class_exists( 'Wdkit_Import_Css' ) )
2538 ? $this->once( 'global_css', array( 'Wdkit_Import_Css', 'globals' ) )
2539 : array( 'available' => false, 'written' => false );
2540
2541 /* Build every imported post's block CSS here, in PHP. The browser's fallback is a
2542 * hidden editor iframe per page at ~8s each; this is ~0.015s each. Reported back so the
2543 * browser can skip that pass when it worked. */
2544 /* The blog posts too: they come from the post kit and are built from the same
2545 * blocks, and were the one set of imported content whose CSS was left for the
2546 * visitor's first view to build.
2547 *
2548 * Chunked the same way as the demo-link rewrite above: ~15ms/page on its own, but
2549 * Wdkit_Import_Css::generate() looped every post with no out_of_time() check either
2550 * (same ClickUp 14ynqxyxffw pass), which adds up across a 100+ page kit on top of
2551 * whatever the rewrite above already spent. finalize_css() calls the same per-post
2552 * Wdkit_Import_Css::rebuild() the unbounded generate() loop did, just budgeted. */
2553 $css = $this->finalize_css( $builder, $pages );
2554
2555 if ( ! empty( $css['pending'] ) ) {
2556 return array(
2557 'navigation' => $navigation,
2558 'demo_links' => $links,
2559 'media' => $media,
2560 'global_css' => $globals,
2561 'css' => $css,
2562 'pending' => $css['pending'],
2563 );
2564 }
2565
2566 /* One render per imported page, so the user's first look at each one is not the
2567 * request that builds its block-asset bundle.
2568 *
2569 * Nexter Blocks builds a page's `theplus-post-{id}.min.css` bundle DURING the page's
2570 * first front-end render, and until it has, it serves that render's block CSS as
2571 * late, async footer `rel="preload"` links. So every imported page - not just the
2572 * front page - showed unstyled on its first visit and correct after a refresh. Only a
2573 * real render records what the bundle needs (`_block_css['blocks']`/`updated_at`), so
2574 * the fix is to render each page once before the user does.
2575 *
2576 * The wizard does that from the success screen with these URLs (see
2577 * warm_pages_now() in import_loader.js): the visitor's own browser can always
2578 * reach the site, which a loopback from here cannot be relied on to. The cron event
2579 * below stays as the fallback for a run with no browser behind it.
2580 *
2581 * Deferred to its own request rather than done here, and that is not a tidiness
2582 * preference — warming asks the site to render its own front page over HTTP, and
2583 * doing that from inside a request that is already holding a PHP worker deadlocks
2584 * on any host with a small worker pool. Measured on a Local install: both the
2585 * site-URL attempt and the 127.0.0.1 fallback timed out at 15s each, so a step
2586 * whose entire purpose is to save the visitor one slow page load was costing 30
2587 * seconds of the import and warming nothing at all.
2588 *
2589 * A one-off cron event runs it after this request has let go of its worker, where
2590 * it can actually succeed. Nothing waits on it: if it never fires, the first
2591 * visitor pays what they would have paid anyway.
2592 *
2593 * A minute out, not seconds: the success screen warms the same pages straight away,
2594 * and this is only for a run with no browser behind it. Warming a page that is
2595 * already built is a normal page view, so the late pass costs nothing. */
2596 $warm_pages = ( 'gutenberg' === $builder ) ? $this->warm_pages( $pages ) : array();
2597 $warm_urls = wp_list_pluck( $warm_pages, 'url' );
2598
2599 if ( ! empty( $css['generated'] ) && ! wp_next_scheduled( 'wdkit_warm_imported_front', array( $warm_urls ) ) ) {
2600 wp_schedule_single_event( time() + 60, 'wdkit_warm_imported_front', array( $warm_urls ) );
2601 }
2602
2603 Wdkit_Ai_Content::clear_payloads( $this->session_id );
2604
2605 /* No lane can still be working by now, and a stale claim row would outlive the run. */
2606 Wdkit_Import_Session::release_all( $this->session_id );
2607
2608 /**
2609 * Fires when a PHP-run import has finished its last stage.
2610 *
2611 * @param string $session_id Session id.
2612 * @param array $context Validated context.
2613 */
2614 do_action( 'wdkit_import_runner_finalized', $this->session_id, $this->context );
2615
2616 /* Cleanup is last and is gated on allow_destructive_cleanup, which defaults to
2617 * false. With the flag off both calls report why they did nothing; with it on, the
2618 * orphan sweep still refuses any id the session cannot prove this run created. */
2619 $cleanup = $this->once(
2620 'cleanup',
2621 function () {
2622 return array(
2623 'sample_post' => Wdkit_Import_Cleanup::remove_sample_post( $this->context ),
2624 'orphans' => Wdkit_Import_Cleanup::remove_orphaned_posts(
2625 $this->orphaned_post_ids(),
2626 $this->context,
2627 $this->session_id
2628 ),
2629 );
2630 }
2631 );
2632
2633 return array(
2634 'finalized' => true,
2635 'navigation' => $navigation,
2636 'demo_links' => $links,
2637 'cleanup' => $cleanup,
2638 'css' => $css,
2639 'global_css' => $globals,
2640 'media' => $media,
2641 'warm_pages' => $warm_pages,
2642 );
2643 }
2644
2645 /**
2646 * Demo-link rewrite, budgeted one page at a time.
2647 *
2648 * Wdkit_Import_Navigation::build_url_rewrite_map() (cheap - array bookkeeping over
2649 * templates/page_records) runs in full every call; the per-page write,
2650 * apply_url_rewrite_to_post(), is what this bounds. Each page gets its own
2651 * `demo_link_<id>` step, exactly the way stage_content() tracks `page_<id>`, so a
2652 * resumed call skips every page already rewritten and only pays for what is left.
2653 *
2654 * @since 2.7.3
2655 *
2656 * @param array[] $pages Page step results (page_records()).
2657 * @return array{scanned:int,rewritten:int,links:int,scrubbed:int,pending?:int}
2658 */
2659 private function finalize_demo_links( $pages ) {
2660 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'demo_links' ) ) {
2661 $session = Wdkit_Import_Session::get( $this->session_id );
2662
2663 return ( null !== $session && ! empty( $session['results']['demo_links'] ) )
2664 ? $session['results']['demo_links']
2665 : array(
2666 'scanned' => 0,
2667 'rewritten' => 0,
2668 'links' => 0,
2669 'scrubbed' => 0,
2670 );
2671 }
2672
2673 $map = Wdkit_Import_Navigation::build_url_rewrite_map(
2674 isset( $this->context['templates'] ) ? $this->context['templates'] : array(),
2675 $pages
2676 );
2677
2678 /* Running total across slices - not itself a gate, just where a resumed call reads
2679 * back what earlier slices already rewrote. Never read through is_step_complete():
2680 * that would make the SECOND slice look "done" the moment the first one wrote it. */
2681 $session = Wdkit_Import_Session::get( $this->session_id );
2682 $progress = ( null !== $session && ! empty( $session['results']['demo_links_progress'] ) && is_array( $session['results']['demo_links_progress'] ) )
2683 ? $session['results']['demo_links_progress']
2684 : array( 'rewritten' => 0, 'scrubbed' => 0 );
2685
2686 $pending = 0;
2687
2688 foreach ( $map['post_ids'] as $post_id ) {
2689 $step = 'demo_link_' . $post_id;
2690
2691 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
2692 continue;
2693 }
2694
2695 /* Checked between pages, never inside one - same rule stage_content() applies to
2696 * its own per-template loop, and for the same reason: a page's rewrite is always
2697 * all-or-nothing, never left half written. */
2698 if ( $this->out_of_time() ) {
2699 ++$pending;
2700 continue;
2701 }
2702
2703 $applied = Wdkit_Import_Navigation::apply_url_rewrite_to_post( $post_id, $map['search'], $map['replace'] );
2704
2705 $progress['scrubbed'] += $applied['scrubbed'];
2706
2707 if ( $applied['rewritten'] ) {
2708 ++$progress['rewritten'];
2709 }
2710
2711 Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, true );
2712 }
2713
2714 if ( $pending > 0 ) {
2715 Wdkit_Import_Session::mark_step_complete( $this->session_id, 'demo_links_progress', $progress );
2716
2717 return array(
2718 'scanned' => count( $map['post_ids'] ),
2719 'rewritten' => $progress['rewritten'],
2720 'links' => $map['links'],
2721 'scrubbed' => $progress['scrubbed'],
2722 'pending' => $pending,
2723 );
2724 }
2725
2726 $result = array(
2727 'scanned' => count( $map['post_ids'] ),
2728 'rewritten' => $progress['rewritten'],
2729 'links' => $map['links'],
2730 'scrubbed' => $progress['scrubbed'],
2731 );
2732
2733 Wdkit_Import_Session::mark_step_complete( $this->session_id, 'demo_links', $result );
2734
2735 return $result;
2736 }
2737
2738 /**
2739 * Per-post block CSS, budgeted one page at a time.
2740 *
2741 * Same shape as finalize_demo_links(): Wdkit_Import_Css::generate() used to loop every
2742 * imported page and blog post with no out_of_time() check (ClickUp 14ynqxyxffw). This
2743 * calls the same per-post Wdkit_Import_Css::rebuild() that loop called, through a
2744 * `css_gen_<id>` step per post so a resumed call only rebuilds what is left.
2745 *
2746 * @since 2.7.3
2747 *
2748 * @param string $builder 'elementor'|'gutenberg'.
2749 * @param array[] $pages Page step results (page_records()).
2750 * @return array{available:bool,generated:int,pending?:int}
2751 */
2752 private function finalize_css( $builder, $pages ) {
2753 if ( ! class_exists( 'Wdkit_Import_Css' ) ) {
2754 return array(
2755 'available' => false,
2756 'generated' => 0,
2757 );
2758 }
2759
2760 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'finalize_css' ) ) {
2761 $session = Wdkit_Import_Session::get( $this->session_id );
2762
2763 return ( null !== $session && ! empty( $session['results']['finalize_css'] ) )
2764 ? $session['results']['finalize_css']
2765 : array(
2766 'available' => true,
2767 'generated' => 0,
2768 );
2769 }
2770
2771 /* For Gutenberg builder kits, both pages and blog posts need CSS generation.
2772 * For Elementor builder kits, the pages are Elementor, but blog posts from post kit 19866
2773 * are ALWAYS Gutenberg blocks, so their CSS must still be generated! */
2774 $post_ids = ( 'gutenberg' === $builder )
2775 ? array_merge( wp_list_pluck( $pages, 'post_id' ), $this->blog_post_ids() )
2776 : $this->blog_post_ids();
2777
2778 $post_ids = array_values(
2779 array_unique(
2780 array_filter(
2781 array_map( 'intval', (array) $post_ids )
2782 )
2783 )
2784 );
2785
2786 if ( empty( $post_ids ) ) {
2787 return array(
2788 'available' => true,
2789 'generated' => 0,
2790 );
2791 }
2792
2793 $session = Wdkit_Import_Session::get( $this->session_id );
2794 $progress = ( null !== $session && ! empty( $session['results']['finalize_css_progress'] ) && is_array( $session['results']['finalize_css_progress'] ) )
2795 ? $session['results']['finalize_css_progress']
2796 : array( 'generated' => 0, 'version' => time() );
2797
2798 $pending = 0;
2799
2800 foreach ( $post_ids as $post_id ) {
2801 $step = 'css_gen_' . $post_id;
2802
2803 if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) {
2804 continue;
2805 }
2806
2807 if ( $this->out_of_time() ) {
2808 ++$pending;
2809 continue;
2810 }
2811
2812 if ( Wdkit_Import_Css::rebuild( $post_id, $progress['version'] ) ) {
2813 ++$progress['generated'];
2814 }
2815
2816 Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, true );
2817 }
2818
2819 if ( $pending > 0 ) {
2820 Wdkit_Import_Session::mark_step_complete( $this->session_id, 'finalize_css_progress', $progress );
2821
2822 return array(
2823 'available' => true,
2824 'generated' => $progress['generated'],
2825 'pending' => $pending,
2826 );
2827 }
2828
2829 $result = array(
2830 'available' => true,
2831 'generated' => $progress['generated'],
2832 );
2833
2834 Wdkit_Import_Session::mark_step_complete( $this->session_id, 'finalize_css', $result );
2835
2836 if ( class_exists( 'Wdkit_Import_Log' ) ) {
2837 Wdkit_Import_Log::add( 'css', $result );
2838 }
2839
2840 return $result;
2841 }
2842
2843 /**
2844 * Everything this run imported that a visitor can open, home first.
2845 *
2846 * Theme-builder templates (headers, footers) are not viewable on their own; they are
2847 * built as part of the pages that render them. The post id travels with each URL so the
2848 * wizard can warm a page again once its own deferred media has been rewritten.
2849 *
2850 * @param array $pages Page step results.
2851 * @return array[] { post_id: int, url: string }
2852 */
2853 private function warm_pages( $pages ) {
2854 $front = (int) get_option( 'page_on_front' );
2855 $list = array(
2856 home_url( '/' ) => array(
2857 'post_id' => $front,
2858 'url' => home_url( '/' ),
2859 ),
2860 );
2861
2862 foreach ( array_merge( wp_list_pluck( $pages, 'post_id' ), $this->blog_post_ids() ) as $post_id ) {
2863 $post_id = (int) $post_id;
2864
2865 if ( $post_id <= 0 || $post_id === $front || 'publish' !== get_post_status( $post_id ) || ! is_post_type_viewable( get_post_type( $post_id ) ) || 'nxt_builder' === get_post_type( $post_id ) ) {
2866 continue;
2867 }
2868
2869 $url = get_permalink( $post_id );
2870
2871 if ( is_string( $url ) && '' !== $url && ! isset( $list[ $url ] ) ) {
2872 $list[ $url ] = array(
2873 'post_id' => $post_id,
2874 'url' => $url,
2875 );
2876 }
2877 }
2878
2879 return array_values( $list );
2880 }
2881
2882 /**
2883 * Posts this run created that no longer belong to the finished site.
2884 *
2885 * The wizard's equivalent is the retry pass: templates that failed are re-imported and
2886 * the posts from the abandoned attempt are removed. Here that means any page step which
2887 * recorded a post and then failed on a later attempt — i.e. a post id present in
2888 * `results` whose step is also present in `failed`.
2889 *
2890 * Returns an empty list when there is nothing to reconcile, which is the normal case.
2891 *
2892 * @return int[]
2893 */
2894 private function orphaned_post_ids() {
2895 $session = Wdkit_Import_Session::get( $this->session_id );
2896
2897 if ( null === $session || empty( $session['failed'] ) || empty( $session['results'] ) ) {
2898 return array();
2899 }
2900
2901 $orphans = array();
2902
2903 foreach ( array_keys( $session['failed'] ) as $step ) {
2904 $step = (string) $step;
2905
2906 if ( 0 !== strpos( $step, 'page_' ) && 0 !== strpos( $step, 'post_' ) ) {
2907 continue;
2908 }
2909
2910 /* A step that later completed is no longer failed — mark_step_complete()
2911 * removes it — so anything still here failed on its last attempt. */
2912 if ( empty( $session['results'][ $step ]['post_id'] ) ) {
2913 continue;
2914 }
2915
2916 $orphans[] = (int) $session['results'][ $step ]['post_id'];
2917 }
2918
2919 return $orphans;
2920 }
2921 }
2922 }
2923