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

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

1,038 lines 35.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Running a queued import through the importer this plugin already has.
4 *
5 * ── What this is, and firmly what it is not ────────────────────────────────
6 *
7 * Wdkit_Import_Remote takes a job off the queue. This puts that job through
8 * `wdkit_create_full_site` — the same entry point the Abilities/MCP surface and WP-CLI use,
9 * which is the same runner the wizard drives. There is no second importer here and there must
10 * never be one: plugin installation, pages, theme-builder templates, media, global colours,
11 * typography, plus-global.css, menus and settings are all things the runner already does, and
12 * a remote copy of any of it would drift from the original the first time either changed.
13 *
14 * So this file is a translator and a reporter. It maps a payload onto arguments, watches the
15 * stages the runner already announces, and tells the queue what happened.
16 *
17 * ── How a retry avoids importing anything twice ────────────────────────────
18 *
19 * Two mechanisms, and they cover different failures.
20 *
21 * A run that got part-way leaves a resumable session. Its id is reported to the queue as soon
22 * as it exists — before any content is written — so a job re-offered after the site died comes
23 * back carrying that id, the runner reopens that session, and every stage and every page
24 * already marked complete is skipped. Nothing is imported a second time.
25 *
26 * A run that never got far enough to have a session, or whose session has since been cleaned
27 * up, starts fresh — and a fresh full-site import resets the site first, which is the
28 * importer's own long-standing behaviour. That is also idempotent, by a blunter route: the
29 * previous attempt's content is removed rather than duplicated.
30 *
31 * ── Why the import happens inside the cron request ─────────────────────────
32 *
33 * `run()` completes a whole import in one call; that is its documented contract and what
34 * `wdkit_create_full_site` depends on. Splitting it across ticks here would mean re-deriving
35 * the slicing the runner already does internally, so instead the request is given as much room
36 * as the host allows and, if it is killed anyway, the held job goes stale, the queue re-offers
37 * it, and the session resumes. The recovery path is the same one a fatal error takes.
38 *
39 * @package Wdesignkit
40 * @since 2.6.6
41 */
42
43 if ( ! defined( 'ABSPATH' ) ) {
44 exit;
45 }
46
47 if ( ! class_exists( 'Wdkit_Import_Job_Executor' ) ) {
48
49 /**
50 * Executes queued imports via the existing full-site entry point.
51 */
52 class Wdkit_Import_Job_Executor {
53
54 /**
55 * Payload keys handed straight to the entry point.
56 *
57 * Deliberately the same list the entry point already forwards to the runner context,
58 * so a remote import is configured through exactly the fields a local one is. Adding a
59 * capability to the importer makes it available here with no change to this file.
60 *
61 * @var string[]
62 */
63 private static $forwarded = array(
64 'templates',
65 'import_type',
66 'site_info',
67 'site_type',
68 'site_description',
69 'site_lang',
70 'site_agency',
71 'site_category_id',
72 'blog_post',
73 'ai_blog',
74 'wirefram_import',
75 'ecommerce',
76 'features',
77 'products',
78 'ai_document',
79 'images',
80 'plugin_catalogue',
81 'kit_global',
82 'font_family',
83 'site_global',
84 'theme_setting',
85 'allow_destructive_cleanup',
86 'update_existing_products',
87 );
88
89 /**
90 * Where each stage leaves the progress bar once it is done.
91 *
92 * Rough by nature — content is most of the work and most of the wait, which is why it
93 * owns the widest band. The point is a bar that moves in proportion to what is
94 * happening, not an estimate anyone should set a timer by.
95 *
96 * @var array<string,int>
97 */
98 private static $stage_progress = array(
99 'install_plugin_theme' => 25,
100 'importing_site_content' => 75,
101 'setting_up_site' => 90,
102 'finalizing_site_setting' => 100,
103 );
104
105 /**
106 * The job being executed, so the stage listener knows what to report against.
107 *
108 * @var array|null
109 */
110 private static $current = null;
111
112 /**
113 * Last progress report, to keep a chatty stage from becoming a chatty network.
114 *
115 * @var float
116 */
117 private static $last_report = 0.0;
118
119 /**
120 * The stage reported in the previous report, to ensure stage boundaries are never dropped.
121 *
122 * @var string
123 */
124 private static $last_stage = '';
125
126 /**
127 * Whether an import already ran in this request, so the continue path stands down.
128 *
129 * @var bool
130 */
131 private static $ran_this_request = false;
132
133 /**
134 * Seconds between progress reports for the same stage.
135 *
136 * @var int
137 */
138 const REPORT_INTERVAL = 2;
139
140 /**
141 * How many entries a report's detail list may carry.
142 *
143 * The report is a blocking request on the import's own critical path, so its body is kept
144 * small on purpose: the counts tell the story, the names are colour.
145 */
146 const DETAIL_PAGE_CAP = 60;
147
148 const DETAIL_PLUGIN_CAP = 30;
149
150 /**
151 * Listen for claimed jobs.
152 *
153 * @return void
154 */
155 public static function init() {
156 add_action( 'wdkit_import_job_claimed', array( __CLASS__, 'execute' ), 10, 1 );
157
158 /* A run that yielded after installing plugins is finished for this request but not
159 * finished as an import. poll() will not offer it again — the site still holds it —
160 * so the next tick has to pick it up from here instead. Later priority than the
161 * poll itself, so a job claimed this tick is not immediately run twice. */
162 add_action( Wdkit_Import_Remote::CRON_HOOK, array( __CLASS__, 'continue_held' ), 20 );
163 }
164
165 /**
166 * Whether this request already gave up its run lock - see the yield in finish().
167 *
168 * @var bool
169 */
170 private static $lock_released = false;
171
172 /**
173 * Carry on with a job this site is already holding.
174 *
175 * Only for a job that has a session to resume into: without one there is nothing to
176 * carry on from, and re-running would start the kit over.
177 *
178 * @return void
179 */
180 public static function continue_held() {
181 /* Claimed and run in this same tick — poll() fired the action and execute() has
182 * already had its turn. Running again now would be a second attempt inside one
183 * request, which is exactly what yielding was meant to avoid. */
184 if ( self::$ran_this_request ) {
185 return;
186 }
187
188 $job = Wdkit_Import_Remote::active_job();
189
190 if ( null === $job || empty( $job['session_id'] ) ) {
191 return;
192 }
193
194 self::execute( $job );
195 }
196
197 /**
198 * Take an exclusive lock on running one job, or fail.
199 *
200 * WP-Cron's own lock lasts WP_CRON_LOCK_TIMEOUT — 60 seconds by default — and a
201 * full-site import routinely runs longer than that. Once it expires the next request to
202 * the site spawns a second cron process, which finds the same job still held and
203 * resumes it: two requests importing one session at the same time. Observed in
204 * practice, twice, as a pair of `job/start` entries with no yield between them.
205 *
206 * The session's per-step guards keep that from duplicating content, but it doubles the
207 * work and makes timings meaningless, so it is worth refusing outright.
208 *
209 * add_option() is the primitive: option_name is UNIQUE, so exactly one caller can
210 * create the row and the losers get false. No transient, because a transient with an
211 * external object cache is not atomic.
212 *
213 * @param int $job_id Job being executed.
214 * @return bool True when this request may proceed.
215 */
216 private static function lock( $job_id ) {
217 $key = 'wdkit_import_job_lock_' . (int) $job_id;
218
219 if ( add_option( $key, time(), '', false ) ) {
220 return true;
221 }
222
223 /* Held. Only steal it once the holder is clearly gone — longer than any single
224 * request should live, so a slow import is never raced by a second one. */
225 $held = (int) get_option( $key, 0 );
226
227 if ( $held > 0 && ( time() - $held ) > 900 ) {
228 update_option( $key, time(), false );
229
230 return true;
231 }
232
233 return false;
234 }
235
236 /**
237 * Release the run lock.
238 *
239 * @param int $job_id Job.
240 * @return void
241 */
242 private static function unlock( $job_id ) {
243 delete_option( 'wdkit_import_job_lock_' . (int) $job_id );
244 }
245
246 /**
247 * Run one claimed job.
248 *
249 * @param array $job Job as Wdkit_Import_Remote stored it.
250 * @return void
251 */
252 public static function execute( $job ) {
253 if ( ! is_array( $job ) || empty( $job['job_id'] ) ) {
254 return;
255 }
256
257 if ( ! has_filter( 'wdkit_create_full_site' ) ) {
258 Wdkit_Import_Remote::report(
259 array(
260 'status' => 'failed',
261 'error' => 'The full-site import entry point is not available on this site.',
262 )
263 );
264
265 return;
266 }
267
268 /* A cron tick has no current user, and the entry point requires manage_options.
269 * Assume the administrator who registered this site for imports for the duration
270 * of the run, and put the context back afterwards — a cron request can go on to
271 * run other people's hooks, and leaving it logged in as an admin would hand them
272 * capabilities they were never meant to have. */
273 $run_as = Wdkit_Import_Remote::run_as_user();
274
275 if ( $run_as <= 0 ) {
276 Wdkit_Import_Remote::report(
277 array(
278 'status' => 'failed',
279 'error' => 'This site has no administrator account to run the import as.',
280 )
281 );
282
283 return;
284 }
285
286 $previous_user = get_current_user_id();
287 wp_set_current_user( $run_as );
288
289 self::$current = $job;
290 self::$last_report = 0.0;
291 self::$last_stage = '';
292
293 /* An import is minutes of work in the worst case and the caller is a cron tick that
294 * nobody is waiting on. Both are best-effort: a host that forbids them just means
295 * the run may be cut short, and a cut-short run is recovered by resume. */
296 if ( function_exists( 'set_time_limit' ) ) {
297 @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- disabled on some hosts; the run still works, it is just bounded.
298 }
299
300 ignore_user_abort( true );
301
302 /* One request per job. Without this, an expired WP-Cron lock lets a second cron
303 * process resume a job the first is still importing. */
304 if ( ! self::lock( $job['job_id'] ) ) {
305 Wdkit_Import_Log::add(
306 'job',
307 array(
308 'what' => 'skipped',
309 'job_id' => $job['job_id'],
310 'why' => 'another request is already running this import',
311 )
312 );
313
314 wp_set_current_user( $previous_user );
315
316 return;
317 }
318
319 self::$ran_this_request = true;
320
321 $args = self::args( $job );
322
323 Wdkit_Import_Log::add(
324 'job',
325 array(
326 'what' => 'start',
327 'job_id' => $job['job_id'],
328 'kit_id' => $args['kit_id'],
329 'builder' => $args['builder'],
330 'attempt' => $job['attempt'],
331 'resume' => ! empty( $args['session_id'] ) ? $args['session_id'] : '',
332 'reset' => ! in_array( 'reset_site', $args['skip'], true ),
333 'run_as' => $run_as,
334 )
335 );
336
337 /* Announce the start before any work, so the website's progress screen stops saying
338 * "waiting for your site" the moment the site actually picks the job up. */
339 Wdkit_Import_Remote::report(
340 array(
341 'status' => 'running',
342 'stage' => 'starting',
343 'progress' => 2,
344 'session_id' => ! empty( $args['session_id'] ) ? $args['session_id'] : '',
345 )
346 );
347
348 add_action( 'wdkit_import_runner_stage', array( __CLASS__, 'on_stage' ), 10, 4 );
349
350 $start = microtime( true );
351 $result = null;
352 $fatal = '';
353
354 try {
355 $result = apply_filters( 'wdkit_create_full_site', array(), $args );
356 } catch ( Throwable $e ) {
357 /* The entry point handles its own import errors and reports them in `errors`;
358 * reaching here means something outside that — a fatal in a dependency, an
359 * exhausted memory limit. Recorded rather than swallowed, because the queue
360 * needs a terminal answer either way. */
361 $fatal = $e->getMessage();
362 }
363
364 remove_action( 'wdkit_import_runner_stage', array( __CLASS__, 'on_stage' ), 10 );
365
366 $elapsed = round( microtime( true ) - $start, 1 );
367
368 self::finish( $job, $result, $fatal, $elapsed );
369
370 self::$current = null;
371
372 /* Already released on a yield - and by now the woken request may hold it, so
373 * deleting it here would drop THAT request's lock. */
374 if ( ! self::$lock_released ) {
375 self::unlock( $job['job_id'] );
376 }
377 wp_set_current_user( $previous_user );
378 }
379
380 /**
381 * Report the outcome and release the job.
382 *
383 * @param array $job The job.
384 * @param array|null $result Entry-point response.
385 * @param string $fatal Uncaught error message, or ''.
386 * @param float $elapsed Seconds taken.
387 * @return void
388 */
389 private static function finish( $job, $result, $fatal, $elapsed ) {
390 $session_id = ( is_array( $result ) && ! empty( $result['session_id'] ) ) ? (string) $result['session_id'] : '';
391 $succeeded = ( '' === $fatal && is_array( $result ) && ! empty( $result['success'] ) );
392
393 /* Stopped on purpose, part-way. Reported as still running and the job stays held,
394 * so the next tick resumes it in a request where the plugins it just installed are
395 * properly loaded. Reporting `completed` here would leave a site with pages but no
396 * front page; reporting `failed` would burn an attempt on a run that did nothing
397 * wrong. */
398 if ( '' === $fatal && is_array( $result ) && ! empty( $result['restart'] ) ) {
399 Wdkit_Import_Log::add(
400 'job',
401 array(
402 'what' => 'yielded',
403 'job_id' => $job['job_id'],
404 'why' => 'plugins installed; resuming in a fresh request',
405 'seconds' => $elapsed,
406 )
407 );
408
409 /* Let go of the run lock BEFORE reporting. The report makes the queue wake this
410 * site straight away (`yielded`), and that wake's request must be able to take
411 * the lock - it arrives while this request is still finishing up. */
412 self::unlock( $job['job_id'] );
413 self::$lock_released = true;
414
415 Wdkit_Import_Remote::report(
416 array(
417 'status' => 'running',
418 'stage' => 'install_plugin_theme',
419 'progress' => 25,
420 'session_id' => $session_id,
421 'yielded' => 1,
422 )
423 );
424
425 /* Continue now, not on the next scheduled tick.
426 *
427 * Yielding exists so the plugins just installed are loaded properly by a fresh
428 * request — it does not need to be a LATER request, just a different one. Waiting
429 * for the recurring poll added up to a full interval of dead time to every
430 * first-time import, which is time nobody is doing anything in.
431 *
432 * A single event dated now, plus a cron spawn, gets that fresh request
433 * immediately on any site whose loopback works. Where it does not (a container
434 * whose own site URL is unreachable from inside it), this is harmless: the event
435 * is simply due, and the next tick or the next visitor picks it up exactly as
436 * before. */
437 wp_schedule_single_event( time(), Wdkit_Import_Remote::CRON_HOOK );
438
439 spawn_cron();
440
441 return;
442 }
443
444 if ( $succeeded ) {
445 $pages = ( ! empty( $result['pages'] ) && is_array( $result['pages'] ) ) ? $result['pages'] : array();
446
447 self::report_ai_credits( $job, $session_id );
448
449 Wdkit_Import_Log::add(
450 'job',
451 array(
452 'what' => 'done',
453 'job_id' => $job['job_id'],
454 'pages' => count( $pages ),
455 'seconds' => $elapsed,
456 )
457 );
458
459 Wdkit_Import_Remote::report(
460 array(
461 'status' => 'completed',
462 'stage' => 'finalizing_site_setting',
463 'progress' => 100,
464 'session_id' => $session_id,
465
466 /* Kept small on purpose: the website shows a link and a page count, and
467 * the full response carries every template's payload. */
468 'result' => array(
469 'site_url' => home_url(),
470 'admin_url' => admin_url(),
471 'pages' => count( $pages ),
472 'home_page_id' => isset( $result['home_page_id'] ) ? $result['home_page_id'] : null,
473 'seconds' => $elapsed,
474
475 /* A run can succeed with individual pages failed — the importer calls
476 * that partial. Reported so the website can say so rather than
477 * claiming everything worked. */
478 'failed_pages' => self::failed_pages( $result ),
479
480 /* Images a page's own media walk could not localise — the page itself
481 * still imported, so this is invisible in failed_pages above. See
482 * ClickUp 14ynqxywnae: previously only ever reached the debug log. */
483 'failed_images' => self::failed_images( $result ),
484
485 /* Why a page kept its template copy, and whether billing reached the
486 * cloud. Otherwise only in this site's own wp_options, which a
487 * sandbox gives nobody a way to read. */
488 'ai_log' => array_slice(
489 array_values(
490 array_filter(
491 Wdkit_Import_Log::read(),
492 function ( $entry ) use ( $job ) {
493 if ( ! empty( $job['claimed_at'] ) && ( $entry['at'] ?? '' ) < gmdate( 'Y-m-d H:i:s', (int) $job['claimed_at'] ) ) {
494 return false;
495 }
496
497 return in_array( $entry['event'] ?? '', array( 'ai_skip', 'ai_credits', 'ai_batch', 'ai_topup', 'stage', 'css', 'warm', 'media_sweep' ), true );
498 }
499 )
500 ),
501 -40
502 ),
503 'message' => isset( $result['message'] ) ? (string) $result['message'] : '',
504 ),
505 )
506 );
507
508 return;
509 }
510
511 $error = $fatal;
512
513 if ( '' === $error ) {
514 $error = ( is_array( $result ) && ! empty( $result['message'] ) )
515 ? (string) $result['message']
516 : 'The import did not complete.';
517
518 $errors = ( is_array( $result ) && ! empty( $result['errors'] ) && is_array( $result['errors'] ) ) ? $result['errors'] : array();
519
520 if ( ! empty( $errors ) ) {
521 $first = reset( $errors );
522 $detail = is_array( $first )
523 ? ( isset( $first['message'] ) ? (string) $first['message'] : wp_json_encode( $first ) )
524 : (string) $first;
525
526 if ( '' !== $detail ) {
527 $error .= ' ' . $detail;
528 }
529 }
530 }
531
532 Wdkit_Import_Log::add(
533 'job',
534 array(
535 'what' => 'failed',
536 'job_id' => $job['job_id'],
537 'seconds' => $elapsed,
538 'error' => $error,
539 'fatal' => ( '' !== $fatal ),
540 )
541 );
542
543 Wdkit_Import_Remote::report(
544 array(
545 'status' => 'failed',
546
547 /* Carried even on failure — it is what lets the queue's next attempt resume
548 * this session instead of starting the kit over. */
549 'session_id' => $session_id,
550 'error' => $error,
551 )
552 );
553 }
554
555 /**
556 * Pages the run recorded as unsuccessful.
557 *
558 * @param array $result Entry-point response.
559 * @return int
560 */
561 /**
562 * Bill the AI copy this job used.
563 *
564 * The plugin's own wizard does this from the browser once its import finishes
565 * (import_loader.js, `wkit_ai_credit_update`). A website-queued job has no browser on
566 * this site, so without this every AI import started from the website went unbilled.
567 * Same totals and endpoint as the wizard; a sandbox with no account session is
568 * identified by its poll token instead.
569 *
570 * @param array $job The job.
571 * @param string $session_id Importer session id.
572 * @return void
573 */
574 private static function report_ai_credits( $job, $session_id ) {
575 $session = ( '' !== $session_id && class_exists( 'Wdkit_Import_Session' ) ) ? Wdkit_Import_Session::get( $session_id ) : null;
576
577 /* Every slice's page records, not just the last request's `pages`. */
578 $pages = array();
579 foreach ( (array) ( $session['results'] ?? array() ) as $step => $record ) {
580 if ( 0 === strpos( (string) $step, 'page_' ) && is_array( $record ) ) {
581 $pages[] = $record;
582 }
583 }
584
585 $used_credit = 0;
586 $real_credit = 0;
587 $success_ids = array();
588 $failed_ids = array();
589
590 foreach ( $pages as $page ) {
591 if ( ! is_array( $page ) || empty( $page['template_id'] ) ) {
592 continue;
593 }
594
595 $credits = ( ! empty( $page['credits'] ) && is_array( $page['credits'] ) ) ? $page['credits'] : array();
596
597 if ( empty( $page['ai_applied'] ) ) {
598 if ( ! empty( $credits ) ) {
599 $failed_ids[] = (string) $page['template_id'];
600 }
601 continue;
602 }
603
604 $success_ids[] = (string) $page['template_id'];
605 $used_credit += isset( $credits['used_credits'] ) ? (float) $credits['used_credits'] : 0;
606 $real_credit += isset( $credits['real_credit'] ) ? (float) $credits['real_credit'] : 0;
607 }
608
609 if ( $used_credit <= 0 ) {
610 return;
611 }
612
613 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
614
615 $body = array(
616 'kit_id' => isset( $job['kit_id'] ) ? (string) $job['kit_id'] : '',
617 'site_url' => untrailingslashit( admin_url() ),
618 'used_credit' => (int) $used_credit,
619 'real_credit' => (int) $real_credit,
620 'ids_success' => implode( ', ', $success_ids ),
621 'ids_failed' => implode( ', ', $failed_ids ),
622 'token' => $token,
623 );
624
625 if ( '' === $token && class_exists( 'Wdkit_Import_Remote' ) ) {
626 $body = array_merge( $body, Wdkit_Import_Remote::cloud_identity() );
627 }
628
629 $response = WDesignKit_Data_Query::get_data( 'ai/history/set', $body, array(), 15 );
630
631 Wdkit_Import_Log::add(
632 'ai_credits',
633 array(
634 'job_id' => $job['job_id'],
635 'used_credit' => (int) $used_credit,
636 'ok' => ( ! is_wp_error( $response ) && ! empty( $response['success'] ) ),
637 )
638 );
639 }
640
641 private static function failed_pages( $result ) {
642 if ( empty( $result['pages'] ) || ! is_array( $result['pages'] ) ) {
643 return 0;
644 }
645
646 $failed = 0;
647
648 foreach ( $result['pages'] as $page ) {
649 if ( is_array( $page ) && empty( $page['success'] ) ) {
650 ++$failed;
651 }
652 }
653
654 return $failed;
655 }
656
657 /**
658 * Images that failed to localise across every page of the run, summed.
659 *
660 * Each page's own `failed_images` (see Wdkit_Page_Importer::import()) is a list, not a
661 * count — kept that way on the page result in case a future caller wants which URLs,
662 * not just how many. This is the one number the completion screen actually needs.
663 *
664 * @param array $result Entry-point response.
665 * @return int
666 */
667 private static function failed_images( $result ) {
668 if ( empty( $result['pages'] ) || ! is_array( $result['pages'] ) ) {
669 return 0;
670 }
671
672 $failed = 0;
673
674 foreach ( $result['pages'] as $page ) {
675 if ( is_array( $page ) && ! empty( $page['failed_images'] ) && is_array( $page['failed_images'] ) ) {
676 $failed += count( $page['failed_images'] );
677 }
678 }
679
680 return $failed;
681 }
682
683 /**
684 * Publish runner stages to the queue as progress.
685 *
686 * Reports on every stage boundary, and at most once every few seconds in between, so a
687 * stage that emits an event per template does not turn into an event per template on
688 * the wire.
689 *
690 * @param string $stage Stage name.
691 * @param string $state 'start'|'done'|'fail'.
692 * @param array $data Stage result or error record.
693 * @param string $session_id Session id.
694 * @return void
695 */
696 public static function on_stage( $stage, $state, $data = array(), $session_id = '' ) {
697 if ( null === self::$current ) {
698 return;
699 }
700
701 $now = microtime( true );
702 $is_stage_change = ( $stage !== self::$last_stage );
703 $boundary = ( 'done' === $state || 'fail' === $state );
704
705 if ( ! $is_stage_change && ! $boundary && ( $now - self::$last_report ) < self::REPORT_INTERVAL ) {
706 return;
707 }
708
709 self::$last_report = $now;
710 self::$last_stage = $stage;
711
712 $done = isset( self::$stage_progress[ $stage ] ) ? (int) self::$stage_progress[ $stage ] : 50;
713
714 $previous = 0;
715 foreach ( self::$stage_progress as $name => $mark ) {
716 if ( $name === $stage ) {
717 break;
718 }
719 $previous = (int) $mark;
720 }
721
722 if ( 'done' === $state ) {
723 $progress = $done;
724 } elseif ( 'progress' === $state && ! empty( $data['pages'] ) ) {
725 $completed_pages = count( (array) $data['pages'] );
726 $pending = ! empty( $data['pending'] ) ? (int) $data['pending'] : 0;
727 $total = $completed_pages + $pending;
728 if ( $total > 0 ) {
729 $ratio = min( 1.0, max( 0.0, $completed_pages / $total ) );
730 $progress = (int) floor( $previous + ( $ratio * ( $done - $previous ) ) );
731 } else {
732 $progress = (int) floor( ( $previous + $done ) / 2 );
733 }
734 } else {
735 $progress = (int) floor( ( $previous + $done ) / 2 );
736 }
737
738 /* A finished last stage is not a finished import — completion is reported once the
739 * entry point has returned, from finish(). */
740 if ( 100 === $progress ) {
741 $progress = 99;
742 }
743
744 $report = array(
745 'status' => 'running',
746 'stage' => $stage,
747 'progress' => $progress,
748
749 /* The first stage event is the earliest point a session id exists. Getting
750 * it to the queue now is what makes a resume possible if this request dies
751 * part-way through the import. */
752 'session_id' => (string) $session_id,
753 );
754
755 $detail = self::stage_detail( $stage, $data );
756
757 if ( ! empty( $detail ) ) {
758 $report['detail'] = $detail;
759 }
760
761 Wdkit_Import_Remote::report( $report );
762 }
763
764 /**
765 * What the stage is doing, in the shape a progress list can render.
766 *
767 * The runner already carries this - which plugins it installed, which pages landed - and
768 * until now on_stage() read the counts out of it and threw the rest away. A headless run
769 * therefore had nothing to show beyond a bar, while the in-browser import showed both
770 * lists. This is the same information, travelling in the report that was being sent
771 * anyway: no extra request, and the reporting cadence is unchanged, so an import costs
772 * exactly what it did before.
773 *
774 * Capped deliberately. A kit with 200 pages would otherwise put tens of kilobytes into
775 * every report, and the titles beyond the first few dozen are not what someone watching a
776 * progress list is reading - the count is. Titles only, no URLs.
777 *
778 * @param string $stage Stage name.
779 * @param array $data The stage's own result.
780 * @return array
781 */
782 private static function stage_detail( $stage, $data ) {
783 if ( ! is_array( $data ) || empty( $data ) ) {
784 return array();
785 }
786
787 $detail = array();
788
789 /* Dependencies record one row per plugin in exactly this shape already - see
790 * Wdkit_Import_Dependencies::install() - so it is passed through rather than rebuilt. */
791 if ( ! empty( $data['rows'] ) && is_array( $data['rows'] ) ) {
792 $plugins = array();
793
794 foreach ( array_slice( $data['rows'], 0, self::DETAIL_PLUGIN_CAP ) as $row ) {
795 if ( empty( $row['plugin_name'] ) ) {
796 continue;
797 }
798
799 $plugins[] = array(
800 'name' => (string) $row['plugin_name'],
801 'status' => isset( $row['status'] ) ? (string) $row['status'] : 'pending',
802 );
803 }
804
805 if ( ! empty( $plugins ) ) {
806 $detail['plugins'] = $plugins;
807 }
808 }
809
810 if ( ! empty( $data['pages'] ) && is_array( $data['pages'] ) ) {
811 $pages = array();
812
813 foreach ( array_slice( $data['pages'], 0, self::DETAIL_PAGE_CAP ) as $page ) {
814 if ( ! is_array( $page ) || empty( $page['title'] ) ) {
815 continue;
816 }
817
818 $pages[] = array(
819 'title' => (string) $page['title'],
820 'status' => 'done',
821 );
822 }
823
824 if ( ! empty( $pages ) ) {
825 $detail['pages'] = $pages;
826 }
827 }
828
829 /* A failed page is the one thing a watcher most needs to see, so failures are added
830 * even once the cap is reached - they are few by nature, and a list that silently
831 * omitted them would be worse than no list. */
832 if ( ! empty( $data['failed'] ) && is_array( $data['failed'] ) ) {
833 foreach ( array_slice( $data['failed'], 0, self::DETAIL_PAGE_CAP ) as $failure ) {
834 $title = '';
835
836 if ( is_array( $failure ) ) {
837 $title = ! empty( $failure['title'] ) ? (string) $failure['title'] : ( ! empty( $failure['step'] ) ? (string) $failure['step'] : '' );
838 }
839
840 if ( '' === $title ) {
841 continue;
842 }
843
844 $detail['pages'][] = array(
845 'title' => $title,
846 'status' => 'fail',
847 );
848 }
849 }
850
851 /* Pages still to come, by title, so the list reads like the plugin wizard's: every
852 * page shown from the start and ticked off as it lands. */
853 if ( ! empty( $data['upcoming'] ) && is_array( $data['upcoming'] ) ) {
854 $room = self::DETAIL_PAGE_CAP - ( isset( $detail['pages'] ) ? count( $detail['pages'] ) : 0 );
855
856 foreach ( array_slice( $data['upcoming'], 0, max( 0, $room ) ) as $title ) {
857 if ( is_string( $title ) && '' !== $title ) {
858 $detail['pages'][] = array(
859 'title' => $title,
860 'status' => 'pending',
861 );
862 }
863 }
864 }
865
866 if ( isset( $data['pending'] ) ) {
867 $detail['pending'] = (int) $data['pending'];
868 }
869
870 return $detail;
871 }
872
873 /**
874 * Copy the visitor's uploaded logo and photos into this site's media library first.
875 *
876 * The website keeps those uploads only until this import completes, then deletes them
877 * (ImportJobController::Report). Most images are fetched later by the background media
878 * sweep, which would find these already gone - so they are brought in here, before
879 * anything is imported, and the rest of the run only ever sees the local URL.
880 *
881 * @param array $args Entry-point arguments.
882 * @return array
883 */
884 private static function localize_temp_uploads( $args ) {
885 $localize = static function ( $url ) {
886 if ( ! is_string( $url ) || false === strpos( $url, '/images/uploads/ai-temp/' ) || ! class_exists( 'Wdkit_Import_Media' ) ) {
887 return $url;
888 }
889
890 $existing = Wdkit_Import_Media::attachment_for_source_url( $url );
891
892 if ( $existing > 0 ) {
893 $local = wp_get_attachment_url( $existing );
894
895 if ( is_string( $local ) && '' !== $local ) {
896 return $local;
897 }
898 }
899
900 $result = Wdkit_Import_Media::sideload( $url );
901
902 return ( ! empty( $result['success'] ) && '' !== $result['url'] ) ? $result['url'] : $url;
903 };
904
905 if ( ! empty( $args['site_info']['logo'] ) && is_array( $args['site_info']['logo'] ) ) {
906 foreach ( $args['site_info']['logo'] as $variant => $url ) {
907 $args['site_info']['logo'][ $variant ] = $localize( $url );
908 }
909 }
910
911 if ( ! empty( $args['images'] ) && is_array( $args['images'] ) ) {
912 foreach ( $args['images'] as $i => $image ) {
913 if ( is_array( $image ) && isset( $image['url'] ) ) {
914 $args['images'][ $i ]['url'] = $localize( $image['url'] );
915 } elseif ( is_string( $image ) ) {
916 $args['images'][ $i ] = $localize( $image );
917 }
918 }
919 }
920
921 return $args;
922 }
923
924 /**
925 * Map a job's payload onto `wdkit_create_full_site` arguments.
926 *
927 * The kit id and builder come from the job record rather than the payload: those are
928 * what the queue addressed the job with, and the payload is only the prepared content.
929 *
930 * @param array $job The job.
931 * @return array
932 */
933 private static function args( $job ) {
934 $payload = ( ! empty( $job['payload'] ) && is_array( $job['payload'] ) ) ? $job['payload'] : array();
935
936 $args = array(
937 'kit_id' => ! empty( $job['kit_id'] ) ? (string) $job['kit_id'] : (string) ( isset( $payload['kit_id'] ) ? $payload['kit_id'] : '' ),
938 'builder' => ! empty( $job['builder'] ) ? (string) $job['builder'] : (string) ( isset( $payload['builder'] ) ? $payload['builder'] : '' ),
939 );
940
941 foreach ( self::$forwarded as $key ) {
942 if ( isset( $payload[ $key ] ) ) {
943 $args[ $key ] = $payload[ $key ];
944 }
945 }
946
947 $args = self::localize_temp_uploads( $args );
948
949 /**
950 * Whether this import uses AI-written copy.
951 *
952 * In this flow the generation happens on the website, before a site is even
953 * chosen, and travels here as `ai_document`. When it is present, `ai_import` is
954 * what makes the importer merge it — and it does not regenerate, because a
955 * supplied document takes precedence over generating one.
956 *
957 * When it is absent, `normal_import` is the right default rather than the
958 * generous-looking one. Defaulting to `ai_import` with no document would have the
959 * plugin generate copy itself: the same work the website already did or chose not
960 * to, charged to the account a second time. A payload that explicitly asks for
961 * `ai_import` still gets it — that is a caller deciding to spend, not us deciding
962 * for them.
963 */
964 if ( empty( $args['import_type'] ) ) {
965 $args['import_type'] = ! empty( $payload['ai_document'] ) ? 'ai_import' : 'normal_import';
966 }
967
968 /**
969 * Steps to skip.
970 *
971 * `reset_site` is the one worth thinking about: it drafts every published page on the
972 * site. "Create full site" has always meant the kit's site rather than the kit added
973 * on top of whatever was there, and for an empty site — a sandbox, a fresh install —
974 * that is right.
975 *
976 * It is NOT right for a job sent from the website to a site the user already runs,
977 * which is what this executor handles. The website offers the user their own listed
978 * sites and tells them their existing pages are left where they are; the payload
979 * carries no `skip`, so the entry point's own default took over and reset anyway.
980 * Job 42 on a real site drafted 69 pages that way, and the user had been told it
981 * would not happen.
982 *
983 * So the default is inverted HERE, for this transport only — a remote job does not
984 * reset unless its payload asks. `Wdkit_Import_Bridge` keeps its own default for the
985 * callers that legitimately want it (MCP `wdkit_create_full_site`, WP-CLI), which are
986 * the ones where the person issuing the command is the person who owns the shell.
987 *
988 * `reset_site => true` in the payload opts back in, which is what the sandbox option
989 * should send once it exists.
990 */
991 $wants_reset = ! empty( $payload['reset_site'] );
992 $args['skip'] = $wants_reset ? array() : array( 'reset_site' );
993
994 if ( ! empty( $payload['skip'] ) && is_array( $payload['skip'] ) ) {
995 $requested = array_values( array_intersect( array_map( 'strval', $payload['skip'] ), array( 'reset_site', 'reset_builders', 'plugin_settings', 'theme_settings', 'enable_widgets' ) ) );
996
997 $args['skip'] = array_values( array_unique( array_merge( $args['skip'], $requested ) ) );
998 }
999
1000 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1001 Wdkit_Import_Log::add(
1002 'job',
1003 array(
1004 'what' => 'reset_site',
1005 'reset' => $wants_reset ? 1 : 0,
1006 'asked_for' => isset( $payload['reset_site'] ) ? 1 : 0,
1007 )
1008 );
1009 }
1010
1011 /* Resume rather than restart. The queue hands back the session id the previous
1012 * attempt reported; the entry point reopens that session and skips every stage and
1013 * every page already marked complete, so nothing is imported twice. */
1014 if ( ! empty( $job['session_id'] ) ) {
1015 $session_id = (string) $job['session_id'];
1016
1017 /* Only a session that still exists is a resume. If it has been cleaned up, the
1018 * entry point builds a fresh context from these args instead — and then `skip`
1019 * DOES decide whether the site is reset. Skipping the reset in that case would
1020 * import the kit on top of the previous attempt's pages, which is the one
1021 * duplication this whole layer exists to prevent. So the flag is only added
1022 * when there is really a session to resume into. */
1023 $resumable = ( class_exists( 'Wdkit_Import_Session' ) && null !== Wdkit_Import_Session::get( $session_id ) );
1024
1025 $args['session_id'] = $session_id;
1026
1027 if ( $resumable && ! in_array( 'reset_site', $args['skip'], true ) ) {
1028 /* Reported as skipped for the caller's step list. The stored context is what
1029 * actually governs a resume, and its reset stage is already complete. */
1030 $args['skip'][] = 'reset_site';
1031 }
1032 }
1033
1034 return $args;
1035 }
1036 }
1037 }
1038