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

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

751 lines 30.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Kit Import wizard adapter — the runner behind the existing browser UI.
4 *
5 * The wizard's progress screen is not being rewritten. It keeps its four accordion rows, its
6 * per-template rows, its sub-step list and its Retry button; this class simply answers each
7 * of its stage requests with the state those widgets already read.
8 *
9 * ── Why one stage per request, and why that is not polling ────────────────
10 *
11 * import_loader.js has always advanced one stage at a time: import_function() has exactly four
12 * branches and the browser calls the next one when the previous finishes. So the browser is
13 * already the scheduler. Running one runner stage per request slots into that unchanged — no
14 * timer, no repeated status poll, no new endpoint. The request count drops from ~25 to 4.
15 *
16 * ── The contract, field by field ──────────────────────────────────────────
17 *
18 * Returned to the browser, mapped from the session:
19 *
20 * stage / status -> import_status[] row for this stage (pending|loading|done|fail)
21 * pages[] -> import_page[] rows {id, label, status}
22 * errors[] -> error_count.current {id, message, description, success}
23 * site_content[] -> the global/plugin/theme sub-step list
24 * plugin_status[] -> the plugin install rows
25 * session_id -> persisted by the wizard so a refresh resumes
26 * home_url -> the preview iframe
27 *
28 * ── Rollback ──────────────────────────────────────────────────────────────
29 *
30 * Nothing here removes anything. Every one of the ~25 original handlers is still registered
31 * and still works; the browser simply stops calling most of them. `wdkit_use_php_runner`
32 * (filter, and `wdkitData.use_php_runner` for the JS side) returns the wizard to the old path
33 * with no code change.
34 *
35 * @package Wdesignkit
36 * @since 2.6.5
37 */
38
39 if ( ! defined( 'ABSPATH' ) ) {
40 exit;
41 }
42
43 if ( ! class_exists( 'Wdkit_Import_Wizard' ) ) {
44
45 /**
46 * Wizard adapter.
47 */
48 class Wdkit_Import_Wizard {
49
50 /**
51 * Is the PHP runner the engine for the wizard?
52 *
53 * The kill switch. Returning false anywhere sends the wizard back down its original
54 * path, because the JS keeps that path intact.
55 *
56 * @return bool
57 */
58 public static function is_enabled() {
59 /**
60 * Filter whether the Kit Import wizard runs on the PHP import runner.
61 *
62 * The runner is the intended engine, so this defaults to true. The browser path is
63 * still present and still works; turn it back on for one site with:
64 *
65 * add_filter( 'wdkit_use_php_runner', '__return_false' );
66 *
67 * That is the rollback if a site hits something this has not covered.
68 *
69 * @param bool $enabled Default true.
70 */
71 return (bool) apply_filters( 'wdkit_use_php_runner', true );
72 }
73
74 /**
75 * Run one stage for the wizard and describe the result in its own terms.
76 *
77 * @param array $args {stage, session_id, kit_id, site_obj, templates, import_type}.
78 * @return array
79 */
80 public static function run_stage( $args ) {
81 $args = is_array( $args ) ? $args : array();
82 $stage = isset( $args['stage'] ) ? sanitize_text_field( (string) $args['stage'] ) : '';
83
84 if ( ! self::is_enabled() ) {
85 return self::error( 'runner_disabled', __( 'PHP import runner is disabled.', 'wdesignkit' ), $stage );
86 }
87
88 $session_id = isset( $args['session_id'] ) ? Wdkit_Import_Session::sanitize_id( (string) $args['session_id'] ) : '';
89 $context = self::build_context( $args );
90
91 $errors = Wdkit_Import_Context::validation_errors( $context );
92
93 if ( ! empty( $errors ) ) {
94 return self::error( 'invalid_context', implode( ' ', $errors ), $stage );
95 }
96
97 /* An existing session is resumed; otherwise one is minted and handed back so the
98 * browser can persist it. This is what makes a mid-import refresh recoverable in a
99 * way progress_key alone never was — progress_key remembers which SCREEN you were
100 * on, the session remembers which templates actually landed. */
101 /* Emergency brake. `update_option( 'wdkit_runner_hard_stop', 1 )` stops every stage
102 * request dead, which is the only way to halt a browser that is looping without
103 * closing its tab. Nothing sets this automatically. */
104 if ( get_option( 'wdkit_runner_hard_stop' ) ) {
105 return self::error( 'runner_hard_stop', __( 'Import is halted by wdkit_runner_hard_stop.', 'wdesignkit' ), $stage );
106 }
107
108 /* The browser sent a session id we cannot find. Minting a fresh one here looks
109 * harmless and is not: the new session knows about no completed steps, so the very
110 * next request re-imports the entire kit, and the browser - which is still looping
111 * on `pending` - does it again, and again. That is how a 13-template kit reached 82
112 * pages. If the caller thinks it has a session and the server disagrees, that is a
113 * hard error, not something to paper over. */
114 if ( '' !== $session_id && null === Wdkit_Import_Session::get( $session_id ) ) {
115 return self::error(
116 'session_lost',
117 __( 'The import session no longer exists. Start the import again.', 'wdesignkit' ),
118 $stage
119 );
120 }
121
122 if ( '' !== $session_id && null !== Wdkit_Import_Session::get( $session_id ) ) {
123 $runner = new Wdkit_Import_Runner( $session_id );
124
125 /* The session's context was frozen on the first stage request, before the
126 * browser's batched AI answer existed. It arrives with this request instead;
127 * normalise it (the same Wdkit_Ai_Content::normalize_document() the context
128 * builder runs, which is what splits the raw section-keyed answer into
129 * raw_pages) and fold it into the stored context now - otherwise every template
130 * falls back to a per-template cloud generation. */
131 if ( ! empty( $context['ai_document'] ) && is_array( $context['ai_document'] ) && class_exists( 'Wdkit_Ai_Content' ) ) {
132 $runner->adopt_ai_document(
133 Wdkit_Ai_Content::normalize_document(
134 $context['ai_document'],
135 isset( $context['builder'] ) ? (string) $context['builder'] : 'elementor'
136 )
137 );
138 }
139 } else {
140 $runner = Wdkit_Import_Runner::for_context( $context, $session_id );
141 $session_id = $runner->get_session_id();
142 }
143
144 /* An explicit Retry from the progress screen clears the failure state first, so steps
145 * that hit the attempt ceiling are eligible again. Anything already imported keeps its
146 * completed step and is not touched. */
147 if ( ! empty( $args['retry'] ) && '' !== $session_id ) {
148 Wdkit_Import_Session::reset_failures( $session_id );
149 }
150
151 $t_stage = microtime( true );
152 $run = $runner->run_stage( $stage );
153
154 if ( class_exists( 'Wdkit_Import_Log' ) ) {
155 Wdkit_Import_Log::add(
156 'stage',
157 array(
158 'session' => $session_id,
159 'stage' => $stage,
160 'ms' => (int) round( ( microtime( true ) - $t_stage ) * 1000 ),
161 'ok' => ! empty( $run['success'] ) ? 1 : 0,
162 'complete' => ! empty( $run['complete'] ) ? 1 : 0,
163 'pending' => isset( $run['pending'] ) ? (int) $run['pending'] : 0,
164 'error' => ! empty( $run['error']['message'] ) ? $run['error']['message'] : '',
165 )
166 );
167 }
168 $session = ( ! empty( $run['session'] ) && is_array( $run['session'] ) ) ? $run['session'] : array();
169
170 $response = array(
171 'success' => ! empty( $run['success'] ),
172 'engine' => 'php_runner',
173 'stage' => $stage,
174 'complete' => ! empty( $run['complete'] ),
175
176 /* Items this stage still owes. Non-zero means "call me again for the same
177 * stage" — a stage cannot import a whole kit inside one PHP request. Dropping
178 * this key made the browser treat a half-finished stage as finished, so a
179 * 13-template kit reported success with 3 pages imported. */
180 'pending' => isset( $run['pending'] ) ? (int) $run['pending'] : 0,
181
182 /* Steps another lane is holding right now.
183 *
184 * The browser's stall detector fires when `pending` stops falling for a few
185 * consecutive responses. That is the right test for "the run has died" and the
186 * wrong one for "a sibling lane is busy": once the pages are done, ONE lane works
187 * through the blog posts (~55s on the reference kit) while the other three keep
188 * asking and keep being told the same non-zero `pending` — three unchanged
189 * answers at roughly 8s a slice, and the screen said "The import stopped making
190 * progress" over an import that was progressing normally. It then finished
191 * anyway, which is how it was diagnosed.
192 *
193 * A live claim is the difference between the two cases, and it is the honest
194 * signal: expired claims are not counted, so a lane that really did die stops
195 * holding the run open once its TTL passes and the stall can fire as designed. */
196 'in_flight' => Wdkit_Import_Session::active_claims( $session_id ),
197 'session_id' => $session_id,
198
199 /* The builder actually in force, which may not be the one the caller asked for: an
200 * unknown builder is resolved from the template's own file_type. The browser adopts
201 * this so its own gutenberg-only passes still run. */
202 'builder' => isset( $session['builder'] ) && is_string( $session['builder'] ) && '' !== $session['builder']
203 ? $session['builder']
204 : $context['builder'],
205 'status' => isset( $session['status'] ) ? $session['status'] : '',
206 'message' => self::message( $run ),
207 'pages' => self::page_rows( $session, $context['templates'], ! empty( $context['blog_post'] ) ),
208 'errors' => self::error_rows( $session ),
209 'site_content' => self::site_content_rows( $session ),
210 'plugin_status' => self::plugin_rows( $session ),
211 /* How many posts had their block CSS built server-side. Non-zero means the browser
212 * can skip its hidden-editor pass: ~8s per page there, ~0.015s per page here, and the
213 * rendered result was compared element by element and matched. */
214 'css_generated' => isset( $run['result']['css']['generated'] ) ? (int) $run['result']['css']['generated'] : 0,
215 /* The deferred media stage 4 queued, in the shape the success screen's
216 * drain_deferred_media() posts back to wkit_run_deferred_media_now - so the runner
217 * path fetches it while the user reads the success screen, as the browser path does,
218 * instead of leaving it to the next wp-cron tick (their own first preview). */
219 'media_pages' => ! empty( $run['result']['media']['pages'] ) ? array_values( (array) $run['result']['media']['pages'] ) : array(),
220 'media_order' => ! empty( $run['result']['media']['order'] ) ? array_values( (array) $run['result']['media']['order'] ) : array(),
221 /* Pages the success screen renders once, so the user's first view of each is not
222 * the one that builds its block-asset bundle - see stage_finalize(). */
223 'warm_pages' => ! empty( $run['result']['warm_pages'] ) ? array_values( (array) $run['result']['warm_pages'] ) : array(),
224 'home_url' => get_home_url(),
225 'stage_error' => ! empty( $run['error'] ) ? $run['error'] : array(),
226
227 /* template_id => {used_credits, real_credit}. The wizard copies this into
228 * credit_count.current so import_success() still reports usage to /after/import
229 * exactly as it did when the browser made the AI calls itself. */
230 'credits' => self::credit_map( $session ),
231
232 /* The content of the page(s) this request just created, so the browser can run
233 * the global-reference rewrite over it and save it back.
234 *
235 * That rewrite (Extract_gutenberg_global / Extract_elementor_global) maps a
236 * kit's global ids onto the ids this site actually assigned. It is ~1000 lines
237 * of browser code with per-reference-type behaviour — some references become a
238 * remapped var(), some a literal value, some keys are expanded — and there is no
239 * PHP equivalent. Reimplementing it would be a second source of truth for the
240 * same rules, so the proven implementation is reused instead: the runner hands
241 * back what it stored, the browser transforms it and posts it to the existing
242 * `wdkit_update_page_content` action.
243 *
244 * Only the pages from THIS slice are included — one per request — so the
245 * response stays small no matter how large the kit is. */
246 'pages_content' => self::slice_content( $run, $context['builder'] ),
247 );
248
249 return $response;
250 }
251
252 /**
253 * Turn the wizard's `site_obj` into a runner context.
254 *
255 * `site_obj` is the object the wizard has been building across its screens, so this is
256 * a rename exercise, not a new data model. Every value still passes through
257 * Wdkit_Import_Context::normalize(), which is what keeps a tampered POST from reaching
258 * the importer — the wizard is trusted no more than any other caller.
259 *
260 * @param array $args Request args.
261 * @return array
262 */
263 private static function build_context( $args ) {
264 $site_obj = ( isset( $args['site_obj'] ) && is_array( $args['site_obj'] ) ) ? $args['site_obj'] : array();
265
266 $context = array(
267 'kit_id' => isset( $args['kit_id'] ) ? $args['kit_id'] : '',
268 'builder' => isset( $args['builder'] ) ? $args['builder'] : 'elementor',
269 'templates' => ( isset( $args['templates'] ) && is_array( $args['templates'] ) ) ? $args['templates'] : array(),
270 'import_type' => isset( $site_obj['import_type'] ) ? $site_obj['import_type'] : 'normal_import',
271
272 /* The wizard's own field names, mapped to the context's. */
273 'site_type' => isset( $site_obj['site_type'] ) ? $site_obj['site_type'] : '',
274 'site_description' => isset( $site_obj['site_description'] ) ? $site_obj['site_description'] : '',
275 'site_lang' => isset( $site_obj['site_lang'] ) ? $site_obj['site_lang'] : 'english',
276 'site_agency' => isset( $site_obj['site_agency'] ) ? $site_obj['site_agency'] : '',
277 'site_category_id' => isset( $site_obj['site_category_id'] ) ? $site_obj['site_category_id'] : '',
278 'site_info' => isset( $site_obj['site_info'] ) ? $site_obj['site_info'] : array(),
279 'images' => isset( $site_obj['images'] ) ? $site_obj['images'] : array(),
280 'site_global' => isset( $site_obj['site_global'] ) ? $site_obj['site_global'] : array(),
281
282 /* The wizard's "Nexter Theme" toggle. */
283 'theme_setting' => ! empty( $site_obj['theme_setting'] ),
284
285 /* The wizard's own names. `kit_global` is the kit palette the globals screen
286 * shows; `font_family` is the swap the visitor picked there. Reading a key the
287 * wizard does not send (there is no site_obj.site_global for the palette) is
288 * what left the imported site unstyled. */
289 'kit_global' => isset( $site_obj['kit_global'] ) ? $site_obj['kit_global'] : array(),
290 'font_family' => isset( $site_obj['font_family'] ) ? $site_obj['font_family'] : array(),
291 'products' => isset( $site_obj['products'] ) ? $site_obj['products'] : array(),
292
293 /* The wizard's "ecommerce" feature switch, reduced to the boolean the context
294 * accepts. This is the exact test import_products_step() applies in the browser
295 * before it calls import_dummy_products(); without it the runner had no way to
296 * tell a shop import from a site that merely happens to run WooCommerce. */
297 'ecommerce' => self::wants_ecommerce( $site_obj ),
298
299 /* Every other feature switch, as names. The runner owns the name -> package
300 * mapping; this only reports which switches the visitor left on. */
301 'features' => self::wanted_features( $site_obj ),
302 'blog_post' => isset( $site_obj['blog_post'] ) ? $site_obj['blog_post'] : true,
303 'wirefram_import' => isset( $site_obj['wirefram_import'] ) ? $site_obj['wirefram_import'] : false,
304
305 /* The wizard resets the site on every import — site_setting() calls the
306 * `reset_site` action unconditionally. Matching that here is the whole point of
307 * "consistent with the browser flow", including disabling the existing Nexter
308 * builder templates before the new ones are imported. */
309 'reset_site' => true,
310
311 /* Optional: an already-generated document. When absent and import_type is
312 * ai_import, the page importer generates per template through the same cloud
313 * endpoint and token the browser used. */
314 'ai_document' => isset( $args['ai_document'] ) ? $args['ai_document'] : null,
315 );
316
317 /* The catalogue kit `plugins_id` values resolve against — the wizard already holds
318 * it as wdkit_meta.plugin. */
319 if ( isset( $args['plugin_catalogue'] ) && is_array( $args['plugin_catalogue'] ) ) {
320 $context['plugin_catalogue'] = $args['plugin_catalogue'];
321 }
322
323 return $context;
324 }
325
326 /**
327 * Which feature switches the visitor left on, as names.
328 *
329 * The switch list itself (`site_obj.plugin_setting`) carries plugin slugs, and
330 * Wdkit_Import_Context drops it for that reason - a payload does not get to name a
331 * package to install. Reduced here to the switch NAMES the feature cards use, which the
332 * runner maps to descriptors it holds itself.
333 *
334 * `required` counts as on for the same reason it does in wants_ecommerce(): the kit
335 * declared that plugin, so the card is ticked and not switchable.
336 *
337 * `value` starts life as the string 'false' and becomes a real boolean once the switch
338 * is touched, so the test below knows about both.
339 *
340 * @since 2.7.3
341 *
342 * @param array $site_obj The wizard's site object.
343 * @return string[]
344 */
345 private static function wanted_features( $site_obj ) {
346 if ( empty( $site_obj['plugin_setting'] ) || ! is_array( $site_obj['plugin_setting'] ) ) {
347 return array();
348 }
349
350 $wanted = array();
351
352 foreach ( $site_obj['plugin_setting'] as $feature ) {
353 if ( ! is_array( $feature ) || empty( $feature['name'] ) ) {
354 continue;
355 }
356
357 $on = ! empty( $feature['required'] );
358
359 if ( ! $on ) {
360 $value = isset( $feature['value'] ) ? $feature['value'] : false;
361
362 $on = is_string( $value )
363 ? ! in_array( strtolower( trim( $value ) ), array( '', '0', 'false', 'no', 'off' ), true )
364 : ! empty( $value );
365 }
366
367 if ( $on ) {
368 $wanted[] = (string) $feature['name'];
369 }
370 }
371
372 return $wanted;
373 }
374
375 /**
376 * Did the visitor switch the ecommerce feature on?
377 *
378 * `site_obj.plugin_setting` is the feature-switch list the Content & Media step builds:
379 * `[{name, value, required, ...}]`, where the two fields mean different things:
380 *
381 * `required` — the KIT declares WooCommerce in its templates' `plugins_id`.
382 * get_plugin_data() sets it; the visitor cannot.
383 * `value` — the eCommerce switch, as the visitor left it.
384 *
385 * Either is a yes. import_products_step() in the browser tests `required` alone, and on
386 * a kit that does not declare WooCommerce — any kit that is not a shop — that makes the
387 * eCommerce switch install the plugin and then create nothing, while the card next to it
388 * promises "an online store with WooCommerce and ready-made product pages". Honouring
389 * the switch is a deliberate, small widening of the browser's test, not an accident: the
390 * visitor asked for a shop and should get one. `required` is still honoured on its own so
391 * a shop kit behaves as before even with the switch untouched.
392 *
393 * `value` starts life as the STRING 'false' and becomes a real boolean once the switch is
394 * touched, so it goes through a truthiness test that knows about 'false' and '0'.
395 *
396 * @since 2.7.2
397 *
398 * @param array $site_obj The wizard's site object.
399 * @return bool
400 */
401 private static function wants_ecommerce( $site_obj ) {
402 if ( empty( $site_obj['plugin_setting'] ) || ! is_array( $site_obj['plugin_setting'] ) ) {
403 return false;
404 }
405
406 foreach ( $site_obj['plugin_setting'] as $plugin ) {
407 if ( ! is_array( $plugin ) || ! isset( $plugin['name'] ) || 'ecommerce' !== $plugin['name'] ) {
408 continue;
409 }
410
411 if ( ! empty( $plugin['required'] ) ) {
412 return true;
413 }
414
415 $value = isset( $plugin['value'] ) ? $plugin['value'] : false;
416
417 if ( is_string( $value ) ) {
418 return ! in_array( strtolower( trim( $value ) ), array( '', '0', 'false', 'no', 'off' ), true );
419 }
420
421 return ! empty( $value );
422 }
423
424 return false;
425 }
426
427 /**
428 * Per-template rows, in the shape `import_page[]` already renders.
429 *
430 * @param array $session Session.
431 * @param array $templates Template records.
432 * @param bool $has_posts Whether this import brings blog posts with it.
433 * @return array[]
434 */
435 private static function page_rows( $session, $templates = array(), $has_posts = false ) {
436 $rows = array();
437
438 /* template id => its title, first pipe-segment only, matching the label the wizard
439 * has always shown (import_kit(): temp.title.split("|")[0]). Without this a FAILED
440 * row rendered with an empty label and just a bare Retry link. */
441 $labels = array();
442
443 foreach ( (array) $templates as $template ) {
444 if ( ! is_array( $template ) || empty( $template['id'] ) ) {
445 continue;
446 }
447
448 $title = isset( $template['title'] ) ? (string) $template['title'] : '';
449
450 $labels[ (string) $template['id'] ] = self::step_label( $title );
451 }
452
453 foreach ( (array) ( $session['results'] ?? array() ) as $step => $record ) {
454 if ( 0 !== strpos( (string) $step, 'page_' ) || ! is_array( $record ) ) {
455 continue;
456 }
457
458 $id = isset( $record['template_id'] ) ? (string) $record['template_id'] : substr( (string) $step, 5 );
459
460 $rows[] = array(
461 'id' => $id,
462 /* Through step_label() either way. `$record['title']` is the title as STORED —
463 * the whole thing, kit suffix included — so taking it raw printed
464 * "Blog Detail Page | Zion Technology" next to rows that had been trimmed to
465 * "Blog", in the same list. */
466 'label' => ! empty( $record['title'] ) ? self::step_label( $record['title'] ) : ( $labels[ $id ] ?? '' ),
467 'status' => 'done',
468 'post_id' => isset( $record['post_id'] ) ? (int) $record['post_id'] : 0,
469 'url' => isset( $record['url'] ) ? $record['url'] : '',
470 );
471 }
472
473 foreach ( (array) ( $session['failed'] ?? array() ) as $step => $record ) {
474 if ( 0 !== strpos( (string) $step, 'page_' ) || ! is_array( $record ) ) {
475 continue;
476 }
477
478 $fid = substr( (string) $step, 5 );
479
480 $rows[] = array(
481 'id' => $fid,
482 'label' => $labels[ $fid ] ?? '',
483 'status' => 'fail',
484 'post_id' => 0,
485 'url' => '',
486 );
487 }
488
489 /* The blog-post row.
490 *
491 * The browser path shows one synthetic row for the whole post import — id 0,
492 * "Importing Blog Posts" while it runs, "Imported Blog Posts" when it lands (see
493 * import_blog_posts_step() in import_loader.js). The runner imports posts inside the
494 * content stage with no row of its own, so a kit with blog posts showed the pages
495 * appear and then a pause with nothing to explain it — the posts were importing, 6 of
496 * 6 every time, just invisibly.
497 *
498 * Same id and same labels, so the existing renderer treats it exactly as it treats
499 * the browser path's row. */
500 if ( $has_posts ) {
501 $done = 0;
502 $failed = 0;
503
504 foreach ( array_keys( (array) ( $session['results'] ?? array() ) ) as $step ) {
505 if ( 0 === strpos( (string) $step, 'post_' ) ) {
506 ++$done;
507 }
508 }
509
510 foreach ( array_keys( (array) ( $session['failed'] ?? array() ) ) as $step ) {
511 if ( 0 === strpos( (string) $step, 'post_' ) ) {
512 ++$failed;
513 }
514 }
515
516 $rows[] = array(
517 'id' => 0,
518 'label' => $done > 0
519 ? __( 'Imported Blog Posts', 'wdesignkit' )
520 : __( 'Importing Blog Posts', 'wdesignkit' ),
521 'status' => $failed > 0 ? 'fail' : ( $done > 0 ? 'done' : 'loading' ),
522 'post_id' => 0,
523 'url' => '',
524 );
525 }
526
527 return $rows;
528 }
529
530 /**
531 * One template's title as the progress list should print it.
532 *
533 * Two things the raw title is not fit for:
534 *
535 * 1. The kit suffix. Catalogue titles are "About Us Page | Zion Technology"; the wizard
536 * has always shown only the first pipe segment, and the pages themselves are stored
537 * with a cleaned title, so a row printing the full string looked like a different
538 * kind of item in the same list.
539 * 2. HTML entities. The catalogue encodes punctuation — `Header &#8211; 2`,
540 * `FAQ&#8217;s` — and these labels are rendered as text by the progress list, so the
541 * entity showed through verbatim instead of as the dash or apostrophe it stands for.
542 *
543 * Decoded first, then split: an entity is never a pipe, but decoding after the split
544 * would leave a label that still has to be decoded by whoever prints it.
545 *
546 * @since 2.7.2
547 *
548 * @param string $title Raw catalogue or stored title.
549 * @return string
550 */
551 private static function step_label( $title ) {
552 $title = html_entity_decode( (string) $title, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
553
554 if ( '' === $title ) {
555 return '';
556 }
557
558 $label = explode( '|', $title )[0];
559
560 return trim( preg_replace( '/\s+/', ' ', $label ) );
561 }
562
563 /**
564 * Failures in the shape `error_count.current` already holds, so the wizard's existing
565 * Retry button keeps working without knowing anything changed.
566 *
567 * @param array $session Session.
568 * @return array[]
569 */
570 private static function error_rows( $session ) {
571 $rows = array();
572
573 foreach ( (array) ( $session['failed'] ?? array() ) as $step => $record ) {
574 if ( ! is_array( $record ) ) {
575 continue;
576 }
577
578 /* Stage-level bookkeeping is not something a user can retry — the per-template
579 * rows beside it are. */
580 if ( isset( $record['code'] ) && 'stage_incomplete' === $record['code'] ) {
581 continue;
582 }
583
584 $rows[] = array(
585 'id' => 0 === strpos( (string) $step, 'page_' ) ? substr( (string) $step, 5 ) : (string) $step,
586 'message' => isset( $record['message'] ) ? $record['message'] : '',
587 'description' => isset( $record['code'] ) ? $record['code'] : '',
588 'success' => false,
589 'retryable' => ! empty( $record['retryable'] ),
590 );
591 }
592
593 return $rows;
594 }
595
596 /**
597 * The global / plugin / theme sub-step list.
598 *
599 * @param array $session Session.
600 * @return array[]
601 */
602 private static function site_content_rows( $session ) {
603 $results = (array) ( $session['results'] ?? array() );
604 $failed = (array) ( $session['failed'] ?? array() );
605
606 $map = array(
607 'global_settings' => 'globals',
608 'plugin_settings' => 'plugin_settings',
609 'theme_settings' => 'theme_settings',
610 );
611
612 $rows = array();
613
614 foreach ( $map as $ui_name => $step ) {
615 if ( isset( $failed[ $step ] ) ) {
616 $status = 'fail';
617 } elseif ( isset( $results[ $step ] ) ) {
618 $status = 'done';
619 } else {
620 $status = 'pending';
621 }
622
623 $rows[] = array(
624 'name' => $ui_name,
625 'status' => $status,
626 );
627 }
628
629 return $rows;
630 }
631
632 /**
633 * Plugin/theme install rows from the dependency stage.
634 *
635 * @param array $session Session.
636 * @return array[]
637 */
638 private static function plugin_rows( $session ) {
639 $dep = ( $session['results'][ Wdkit_Import_Runner::STAGE_DEPENDENCIES ] ?? array() );
640
641 if ( ! is_array( $dep ) ) {
642 return array();
643 }
644
645 /* Wdkit_Import_Dependencies::install() records these already shaped for the
646 * progress list: plugin_name, freepro, type, and a status from the wizard's own
647 * vocabulary (active|loading|inactive|unavailable|fail). Building them from the
648 * slug-only keys instead is what produced "undefined Free undefined not installed". */
649 if ( ! empty( $dep['rows'] ) && is_array( $dep['rows'] ) ) {
650 return array_values( $dep['rows'] );
651 }
652
653 return array();
654 }
655
656 /**
657 * Stored content of the pages created in this request.
658 *
659 * Read back from the database rather than carried through the session, so nothing large
660 * is persisted in an option.
661 *
662 * @param array $run run_stage() result.
663 * @param string $builder Builder.
664 * @return array[]
665 */
666 private static function slice_content( $run, $builder ) {
667 /* Nothing is sent back for the browser to rewrite, for either builder — the global
668 * remap now happens entirely inside the importer, before the content is written.
669 *
670 * Gutenberg: Wdkit_Import_Globals::rewrite_kit_references() does it on the parsed
671 * blocks, before the markup is serialised.
672 *
673 * Elementor: Wdkit_Import_Globals::rewrite_elementor_global_ids() does it on the
674 * content string in Wdkit_Page_Importer::store(), using the same
675 * elementor_id_map() that from_kit() writes the globals under. That map closes what
676 * was recorded here as a KNOWN GAP: the runner now detects an `_id` collision between
677 * a kit global and one already on the site and mints a replacement, the way
678 * import_globla_data() does, instead of letting without_known() drop the kit entry and
679 * leave the page rendering in the site's colour.
680 *
681 * So there is nothing left for the browser to fix up, and no reason to ship a 200KB
682 * page back and forth to do it. `$run` and `$builder` are kept in the signature because
683 * the caller's shape is part of the response contract. */
684 return array();
685 }
686
687 /**
688 * template_id => credits, for the wizard's existing usage report.
689 *
690 * @param array $session Session.
691 * @return array<string,array>
692 */
693 private static function credit_map( $session ) {
694 $map = array();
695
696 foreach ( (array) ( $session['results'] ?? array() ) as $step => $record ) {
697 if ( 0 !== strpos( (string) $step, 'page_' ) || ! is_array( $record ) || empty( $record['credits'] ) ) {
698 continue;
699 }
700
701 $id = isset( $record['template_id'] ) ? (string) $record['template_id'] : substr( (string) $step, 5 );
702
703 $map[ $id ] = $record['credits'];
704 }
705
706 return $map;
707 }
708
709 /**
710 * A message the wizard can show without inventing copy.
711 *
712 * @param array $run run_stage() result.
713 * @return string
714 */
715 private static function message( $run ) {
716 if ( ! empty( $run['error']['message'] ) ) {
717 return (string) $run['error']['message'];
718 }
719
720 return ! empty( $run['success'] ) ? '' : __( 'Import stage did not complete.', 'wdesignkit' );
721 }
722
723 /**
724 * The error shape, matching the success shape key-for-key so the JS has one branch.
725 *
726 * @param string $code Machine code.
727 * @param string $message Human message.
728 * @param string $stage Stage.
729 * @return array
730 */
731 private static function error( $code, $message, $stage ) {
732 return array(
733 'success' => false,
734 'engine' => 'php_runner',
735 'stage' => $stage,
736 'complete' => false,
737 'pending' => 0,
738 'session_id' => '',
739 'status' => 'failed',
740 'message' => $message,
741 'pages' => array(),
742 'errors' => array( array( 'id' => 0, 'message' => $message, 'description' => $code, 'success' => false, 'retryable' => false ) ),
743 'site_content' => array(),
744 'plugin_status' => array(),
745 'home_url' => get_home_url(),
746 'stage_error' => array( 'code' => $code, 'message' => $message, 'retryable' => false ),
747 );
748 }
749 }
750 }
751