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-context.php

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

794 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import context — the validated form of `site_obj`.
4 *
5 * In the wizard, `site_obj` is accumulated across four screens (import_temp_preview →
6 * import_temp_feature → import_temp_method → import_content_media) and every value is
7 * something the site's own admin typed or picked. A PHP runner has no such guarantee: its
8 * context may eventually arrive from somewhere else entirely, so this class is the single
9 * place where a context is checked before anything acts on it.
10 *
11 * The rules here are deliberately strict and deliberately boring:
12 *
13 * - Enumerated fields (language, industry, import type, builder) must match a known value
14 * or fall back to the default. Never passed through.
15 * - `plugin_setting` is **dropped**, and `theme_setting` is accepted only as a BOOLEAN.
16 * Which plugins an import installs is derived from the kit's own dependency list, never
17 * from the context - otherwise a context becomes a way to install arbitrary plugins.
18 * Dropping the theme flag outright went too far: the wizard's "Nexter Theme" toggle stopped
19 * doing anything, so an import that used to switch the site's theme silently did not, and
20 * the theme never appeared in the progress list either. A boolean cannot name a package, so
21 * the property that matters is kept - the theme itself is a fixed descriptor defined in the
22 * runner, not something the caller supplies.
23 * - `reset_site` defaults to **false**. The wizard's own default is destructive (it drafts
24 * every published page); a non-interactive runner must never inherit that silently.
25 * - Image URLs are SSRF-validated with the plugin's existing resolver.
26 *
27 * Nothing here is wired to the website. It is the contract a Phase 2 payload will have to
28 * satisfy, established now so the runner can be written against it.
29 *
30 * @package Wdesignkit
31 * @since 2.6.5
32 */
33
34 if ( ! defined( 'ABSPATH' ) ) {
35 exit;
36 }
37
38 if ( ! class_exists( 'Wdkit_Import_Context' ) ) {
39
40 /**
41 * Validated import context.
42 */
43 class Wdkit_Import_Context {
44
45 /**
46 * Languages the AI prompt accepts. Mirrors `lang_list` in import_content_media.js.
47 *
48 * @var string[]
49 */
50 private static $languages = array(
51 'arabic', 'bengali', 'burmese', 'chinese', 'dutch', 'english', 'filipino',
52 'french', 'german', 'greek', 'gujarati', 'hebrew', 'hindi', 'indonesian',
53 'italian', 'japanese', 'kannada', 'korean', 'malay', 'marathi', 'nepali',
54 'persian', 'polish', 'portuguese', 'punjabi', 'romanian', 'russian', 'spanish',
55 'swedish', 'tamil', 'telugu', 'thai', 'turkish', 'urdu', 'vietnamese',
56 );
57
58 /**
59 * Team-photo collection ids. Mirrors `agency_list` in import_content_media.js.
60 *
61 * @var string[]
62 */
63 private static $industries = array( '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15' );
64
65 /**
66 * Import modes the wizard offers.
67 *
68 * @var string[]
69 */
70 private static $import_types = array( 'normal_import', 'ai_import' );
71
72 /**
73 * Builders a kit can be authored in.
74 *
75 * @var string[]
76 */
77 private static $builders = array( 'elementor', 'gutenberg' );
78
79 /**
80 * Keys accepted on `site_info`. Anything else is discarded.
81 *
82 * @var string[]
83 */
84 private static $site_info_keys = array( 'site_name', 'tagline', 'email', 'phone', 'address', 'logo', 'social_links' );
85
86 /**
87 * Social networks the template's `social_links` may carry. Mirrors `social_list`
88 * in import_temp_preview.js.
89 *
90 * @var string[]
91 */
92 private static $social_keys = array( 'facebook', 'twitter', 'instagram', 'linkedin', 'youtube' );
93
94 /**
95 * Build a validated context from a loose array.
96 *
97 * Always returns a complete, usable context — invalid values become defaults rather
98 * than errors, so a runner never has to reason about half-populated input.
99 *
100 * @param array $args Raw context.
101 * @return array
102 */
103 public static function normalize( $args ) {
104 $args = is_array( $args ) ? $args : array();
105
106 $context = array(
107 'kit_id' => self::text( self::pick( $args, 'kit_id' ), 64 ),
108 'builder' => self::enum( self::pick( $args, 'builder' ), self::$builders, 'elementor' ),
109
110 /* Did the caller actually name a builder, or is the line above just reporting the
111 * default? enum() cannot tell those apart afterwards, and the difference decides
112 * whether a builder/content mismatch is a real error or a missing hint. */
113 'builder_explicit' => in_array( self::pick( $args, 'builder' ), self::$builders, true ),
114 'import_type' => self::enum( self::pick( $args, 'import_type' ), self::$import_types, 'normal_import' ),
115
116 /** AI copy fields — these feed the prompt, so length is capped, markup stripped */
117 'site_type' => self::text( self::pick( $args, 'site_type' ), 160 ),
118 'site_description' => self::description( self::pick( $args, 'site_description' ) ),
119 'site_lang' => self::enum( self::lower( self::pick( $args, 'site_lang' ) ), self::$languages, 'english' ),
120 'site_agency' => self::enum( self::text( self::pick( $args, 'site_agency' ), 2 ), self::$industries, '' ),
121 'site_category_id' => self::text( self::pick( $args, 'site_category_id' ), 12 ),
122
123 'site_info' => self::site_info( self::pick( $args, 'site_info' ) ),
124 'images' => self::images( self::pick( $args, 'images' ) ),
125 'templates' => self::templates( self::pick( $args, 'templates' ) ),
126
127 /* The catalogue kit `plugins_id` values resolve against — the same list the
128 * browser receives as `wdkit_meta.plugin`. Validated to the fields the installer
129 * reads, and note what this does NOT enable: a caller cannot install anything
130 * by adding an entry here, because a dependency only exists if some template's
131 * `plugins_id` names its id. The catalogue can narrow, never widen. */
132 'plugin_catalogue' => self::plugin_catalogue( self::pick( $args, 'plugin_catalogue' ) ),
133
134 /* Site-level globals (container width, body background, __globals__). Shape is
135 * re-checked by Wdkit_Import_Globals before anything is written. */
136 'site_global' => is_array( self::pick( $args, 'site_global' ) ) ? self::pick( $args, 'site_global' ) : array(),
137
138 /* The KIT's own global palette and typography, as the wizard carries it
139 * (site_obj.kit_global = {color, typo}). Distinct from `site_global` above,
140 * which is site-level layout values. Shape is re-checked by
141 * Wdkit_Import_Globals::from_kit() before anything is written. */
142 'kit_global' => is_array( self::pick( $args, 'kit_global' ) ) ? self::pick( $args, 'kit_global' ) : array(),
143
144 /* Font swaps chosen on the fonts screen: [{original, replace}]. */
145 'font_family' => is_array( self::pick( $args, 'font_family' ) ) ? self::pick( $args, 'font_family' ) : array(),
146
147 /** booleans the wizard sets on the method screen */
148 'wirefram_import' => self::bool( self::pick( $args, 'wirefram_import' ), false ),
149 'blog_post' => self::bool( self::pick( $args, 'blog_post' ), true ),
150 /* Whether imported blog posts get AI copy. Posts are imported either way. */
151 'ai_blog' => self::bool( self::pick( $args, 'ai_blog' ), true ),
152
153 /* Destructive, and off unless something explicitly asks for it. The wizard
154 * keeps its own default; this one is for non-interactive runs. */
155 /* Whether to install and activate the Nexter theme. A flag, never a slug. */
156 'theme_setting' => self::bool( self::pick( $args, 'theme_setting' ), false ),
157
158 /* Whether this import wants a shop. A flag, for the same reason theme_setting is
159 * one: `plugin_setting` itself is dropped by this normaliser (see the class note),
160 * so the wizard reduces its ecommerce entry to this boolean before handing it
161 * over. Wdkit_Import_Products reads it to decide whether an empty catalogue means
162 * "this kit ships no products" or "fall back to the demo six" — a distinction
163 * WooCommerce merely being active cannot make. */
164 'ecommerce' => self::bool( self::pick( $args, 'ecommerce' ), false ),
165
166 /* The rest of the wizard's feature switches, as NAMES from a fixed list - never
167 * plugin slugs. `plugin_setting` (which does carry slugs) is dropped by this
168 * normaliser on purpose, so the wizard reduces its switches to these names and
169 * the runner maps each one to a descriptor it holds itself. A caller can ask for
170 * "security"; it cannot name a package to install.
171 *
172 * Without this the switches did nothing at all on the PHP-runner path: the
173 * runner installed the kit's own dependencies and the theme, and every plugin
174 * the visitor had asked for with a switch was silently skipped
175 * (ClickUp 14ynqxyykan). */
176 'features' => self::features( self::pick( $args, 'features' ) ),
177
178 'reset_site' => self::bool( self::pick( $args, 'reset_site' ), false ),
179
180 /* Disabling the site's existing header/footer/etc. templates — see
181 * Wdkit_Import_Reset::run(). Not destructive to page content, so it defaults to
182 * whatever reset_site resolved to UNLESS a caller named it explicitly (a bare
183 * bool default here cannot express "mirror the other field", so the array_key
184 * check happens in Reset::run() itself, against this raw args array — the
185 * normalized context still needs the key to exist and carry the caller's
186 * explicit choice through untouched when they made one). */
187 'reset_builders' => array_key_exists( 'reset_builders', $args )
188 ? self::bool( self::pick( $args, 'reset_builders' ), false )
189 : null,
190
191 /* Gates every permanent deletion in Wdkit_Import_Cleanup — the sample-post
192 * removal and the retry-orphan sweep. Default false, and nothing flips it
193 * implicitly. Even when true, cleanup still refuses ids the session cannot
194 * prove this run created. */
195 'allow_destructive_cleanup' => self::bool( self::pick( $args, 'allow_destructive_cleanup' ), false ),
196
197 /* Products the kit ships, if any. Validated per-record by
198 * Wdkit_Import_Products::normalize() rather than here, because the shape is
199 * the generator's, not ours. */
200 'products' => is_array( self::pick( $args, 'products' ) ) ? self::pick( $args, 'products' ) : array(),
201
202 /* Pre-generated AI content for the whole site, normalised through the canonical
203 * document contract. This is the field a future remote caller fills so the
204 * import can run without a browser performing the merge.
205 *
206 * Normalising here rather than at the point of use is the security boundary:
207 * whatever arrives, what reaches the importer is a closed set of page element
208 * replacements plus whitelisted product and post fields. Unknown keys are gone
209 * by the time anything reads this. */
210 'ai_document' => self::ai_document( self::pick( $args, 'ai_document' ), self::enum( self::pick( $args, 'builder' ), self::$builders, 'elementor' ) ),
211
212 /* Whether a product this importer previously created may have its copy
213 * refreshed. The browser never updates — it only creates — so this is off by
214 * default, and even on it can only touch products carrying our own marker. */
215 'update_existing_products' => self::bool( self::pick( $args, 'update_existing_products' ), false ),
216 );
217
218 return $context;
219 }
220
221 /**
222 * Normalise a supplied AI document.
223 *
224 * Delegates to the contract rather than re-validating here, so there is one definition
225 * of what a payload may contain.
226 *
227 * @param mixed $value Raw document.
228 * @param string $builder Builder.
229 * @return array
230 */
231 private static function ai_document( $value, $builder ) {
232 if ( null === $value || '' === $value ) {
233 return array();
234 }
235
236 if ( ! class_exists( 'Wdkit_Ai_Content' ) ) {
237 return array();
238 }
239
240 return Wdkit_Ai_Content::normalize_document( $value, $builder );
241 }
242
243 /**
244 * Fetch a key, tolerating both canonical and wizard-style names.
245 *
246 * @param array $args Raw context.
247 * @param string $key Canonical key.
248 * @return mixed|null
249 */
250 private static function pick( $args, $key ) {
251 if ( array_key_exists( $key, $args ) ) {
252 return $args[ $key ];
253 }
254
255 /** the wizard's own aliases, so an existing site_obj can be passed straight in */
256 $aliases = array(
257 'site_description' => array( 'site_desc', 'description' ),
258 'site_lang' => array( 'language' ),
259 'site_agency' => array( 'agency', 'industry' ),
260 'site_type' => array( 'type' ),
261 'builder' => array( 'kit_builder', 'editor' ),
262 );
263
264 if ( isset( $aliases[ $key ] ) ) {
265 foreach ( $aliases[ $key ] as $alias ) {
266 if ( array_key_exists( $alias, $args ) ) {
267 return $args[ $alias ];
268 }
269 }
270 }
271
272 return null;
273 }
274
275 /**
276 * An absolute URL, or '' when the value is not one.
277 *
278 * @param mixed $value Raw URL.
279 * @return string
280 */
281 private static function url( $value ) {
282 if ( ! is_string( $value ) || '' === $value ) {
283 return '';
284 }
285
286 $url = esc_url_raw( trim( $value ) );
287
288 return ( strlen( $url ) > 2048 ) ? '' : $url;
289 }
290
291 /**
292 * Trim and length-cap a scalar.
293 *
294 * @param mixed $value Raw.
295 * @param int $limit Maximum length.
296 * @return string
297 */
298 private static function text( $value, $limit ) {
299 if ( is_array( $value ) || is_object( $value ) || is_null( $value ) ) {
300 return '';
301 }
302
303 $value = sanitize_text_field( (string) $value );
304
305 return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $limit ) : substr( $value, 0, $limit );
306 }
307
308 /**
309 * The description can be an array in the wizard (variants, with an active index), so
310 * the first non-empty entry is taken when that shape arrives.
311 *
312 * @param mixed $value Value.
313 * @return string
314 */
315 private static function description( $value ) {
316 if ( is_array( $value ) ) {
317 foreach ( $value as $entry ) {
318 if ( is_string( $entry ) && '' !== trim( $entry ) ) {
319 $value = $entry;
320 break;
321 }
322 }
323 }
324
325 if ( ! is_string( $value ) ) {
326 return '';
327 }
328
329 $value = sanitize_textarea_field( $value );
330
331 return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 1500 ) : substr( $value, 0, 1500 );
332 }
333
334 /**
335 * @param mixed $value Value.
336 * @return string
337 */
338 private static function lower( $value ) {
339 return is_string( $value ) ? strtolower( trim( $value ) ) : '';
340 }
341
342 /**
343 * @param mixed $value Value.
344 * @param string[] $allowed Allowed values.
345 * @param string $default Fallback.
346 * @return string
347 */
348 /**
349 * The wizard's feature switches, reduced to a known-name list.
350 *
351 * @since 2.7.3
352 *
353 * @param mixed $value Raw value from the payload.
354 * @return string[] Names from self::FEATURES only, de-duplicated.
355 */
356 private static function features( $value ) {
357 if ( ! is_array( $value ) ) {
358 return array();
359 }
360
361 $known = array( 'ecommerce', 'dynamic_content', 'performance', 'security', 'extras' );
362 $out = array();
363
364 foreach ( $value as $name ) {
365 $name = is_string( $name ) ? strtolower( trim( $name ) ) : '';
366
367 if ( '' !== $name && in_array( $name, $known, true ) && ! in_array( $name, $out, true ) ) {
368 $out[] = $name;
369 }
370 }
371
372 return $out;
373 }
374
375 private static function enum( $value, $allowed, $default ) {
376 $value = is_string( $value ) ? trim( $value ) : '';
377
378 return in_array( $value, $allowed, true ) ? $value : $default;
379 }
380
381 /**
382 * @param mixed $value Value.
383 * @param bool $default Fallback when absent.
384 * @return bool
385 */
386 private static function bool( $value, $default ) {
387 if ( null === $value ) {
388 return (bool) $default;
389 }
390
391 return filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) ?? (bool) $default;
392 }
393
394 /**
395 * Validate the business details that fill the template's own site_info fields.
396 *
397 * @param mixed $value Raw site_info.
398 * @return array
399 */
400 private static function site_info( $value ) {
401 if ( ! is_array( $value ) ) {
402 return array();
403 }
404
405 $info = array();
406
407 foreach ( self::$site_info_keys as $key ) {
408 if ( ! array_key_exists( $key, $value ) ) {
409 continue;
410 }
411
412 if ( 'social_links' === $key ) {
413 $info['social_links'] = self::social_links( $value[ $key ] );
414 continue;
415 }
416
417 if ( 'email' === $key ) {
418 $email = is_string( $value[ $key ] ) ? sanitize_email( $value[ $key ] ) : '';
419
420 /** a malformed address is dropped, not stored — it would render on the page */
421 $info['email'] = is_email( $email ) ? $email : '';
422 continue;
423 }
424
425 if ( 'logo' === $key ) {
426 $info['logo'] = self::logo( $value[ $key ] );
427 continue;
428 }
429
430 if ( 'address' === $key ) {
431 $info['address'] = self::description( $value[ $key ] );
432 continue;
433 }
434
435 $info[ $key ] = self::text( $value[ $key ], 200 );
436 }
437
438 return $info;
439 }
440
441 /**
442 * @param mixed $value Raw social links.
443 * @return array
444 */
445 private static function social_links( $value ) {
446 if ( ! is_array( $value ) ) {
447 return array();
448 }
449
450 $links = array();
451
452 foreach ( self::$social_keys as $network ) {
453 if ( empty( $value[ $network ] ) || ! is_string( $value[ $network ] ) ) {
454 continue;
455 }
456
457 $url = esc_url_raw( trim( $value[ $network ] ) );
458
459 if ( '' !== $url ) {
460 $links[ $network ] = $url;
461 }
462 }
463
464 return $links;
465 }
466
467 /**
468 * Validate one image URL, refusing anything the SSRF guard rejects.
469 *
470 * @param mixed $value Raw URL.
471 * @return string Empty string when unusable.
472 */
473 /**
474 * The site logo, which is a MAP of variants — not a single URL.
475 *
476 * The wizard sends `{ default: '<url>' }` and may send more keys beside it
477 * (`build_site_info()` in import_temp_start.jsx), and the importer reads them by name:
478 * `site_obj.site_info?.logo?.default`, then `site_obj.site_info?.logo?.[wdkitai_site_logo]`
479 * for a block that asks for a specific variant. That map IS the contract.
480 *
481 * This used to go straight to image_url(), which starts with `! is_string( $value )` and
482 * therefore returned '' for every object it was handed — so the logo was dropped from the
483 * context on its way in, silently, on every single run. The chat said "Logo uploaded ✓",
484 * the kit had four blocks marked `site_logo` waiting for it, and the imported site kept
485 * the kit's own logo. The browser path never had this problem because it reads `site_obj`
486 * directly and no PHP context stands between the two.
487 *
488 * A plain string is still accepted and normalised to `{ default: … }`, so a caller that
489 * only ever had one URL (the bridge, MCP, WP-CLI) keeps working and gains the map shape
490 * the importer wants.
491 *
492 * @since 2.7.2
493 *
494 * @param mixed $value Raw logo — map of variants, or a single URL.
495 * @return array|string Map of variant => URL, or '' when nothing usable survived.
496 */
497 private static function logo( $value ) {
498 if ( is_string( $value ) ) {
499 $url = self::image_url( $value );
500
501 return ( '' === $url ) ? '' : array( 'default' => $url );
502 }
503
504 if ( ! is_array( $value ) ) {
505 return '';
506 }
507
508 $out = array();
509
510 foreach ( $value as $variant => $url ) {
511 /* Variant names travel into the block payload, so they are held to the same
512 * shape as every other key the importer looks up by name. */
513 $variant = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $variant );
514
515 if ( '' === $variant ) {
516 continue;
517 }
518
519 $clean = self::image_url( $url );
520
521 if ( '' !== $clean ) {
522 $out[ $variant ] = $clean;
523 }
524 }
525
526 return empty( $out ) ? '' : $out;
527 }
528
529 private static function image_url( $value ) {
530 if ( ! is_string( $value ) || '' === trim( $value ) ) {
531 return '';
532 }
533
534 $url = esc_url_raw( trim( $value ) );
535
536 if ( '' === $url ) {
537 return '';
538 }
539
540 /** local uploads are fine and would otherwise be refused as a private address */
541 $uploads = wp_get_upload_dir();
542
543 if ( ! empty( $uploads['baseurl'] ) && 0 === strpos( $url, $uploads['baseurl'] ) ) {
544 return $url;
545 }
546
547 if ( function_exists( 'wdesignkit_validate_external_url' ) && ! wdesignkit_validate_external_url( $url ) ) {
548 return '';
549 }
550
551 return $url;
552 }
553
554 /**
555 * Validate the chosen image pool.
556 *
557 * Accepts either bare URLs or {url, width, height} records, since the wizard carries
558 * dimensions it already knows and a runner may not.
559 *
560 * @param mixed $value Raw images.
561 * @return array[]
562 */
563 private static function images( $value ) {
564 if ( ! is_array( $value ) ) {
565 return array();
566 }
567
568 $images = array();
569
570 foreach ( $value as $entry ) {
571 $url = is_array( $entry ) ? ( isset( $entry['url'] ) ? $entry['url'] : '' ) : $entry;
572 $url = self::image_url( $url );
573
574 if ( '' === $url ) {
575 continue;
576 }
577
578 $image = array( 'url' => $url );
579
580 if ( is_array( $entry ) ) {
581 if ( ! empty( $entry['width'] ) ) {
582 $image['width'] = (int) $entry['width'];
583 }
584
585 if ( ! empty( $entry['height'] ) ) {
586 $image['height'] = (int) $entry['height'];
587 }
588 }
589
590 $images[] = $image;
591
592 /** a pool larger than this is a payload problem, not a design choice */
593 if ( count( $images ) >= 60 ) {
594 break;
595 }
596 }
597
598 return $images;
599 }
600
601 /**
602 * Validate the list of templates to import.
603 *
604 * @param mixed $value Raw templates.
605 * @return array[]
606 */
607 private static function templates( $value ) {
608 if ( ! is_array( $value ) ) {
609 return array();
610 }
611
612 $templates = array();
613
614 foreach ( $value as $entry ) {
615 if ( ! is_array( $entry ) || empty( $entry['id'] ) ) {
616 continue;
617 }
618
619 $templates[] = array(
620 'id' => self::text( $entry['id'], 64 ),
621 'title' => self::text( isset( $entry['title'] ) ? $entry['title'] : '', 200 ),
622 'type' => self::enum( isset( $entry['type'] ) ? $entry['type'] : 'page', array( 'page', 'section' ), 'page' ),
623 'wp_post_type' => self::text( isset( $entry['wp_post_type'] ) ? $entry['wp_post_type'] : 'page', 32 ),
624
625 /* The template's URL on the demo site, kept so the finalize stage can point
626 * the kit's internal links at the imported pages instead. Run through
627 * esc_url_raw(), and only ever used as the SEARCH half of a replacement —
628 * never emitted into content — so a hostile value can at worst fail to
629 * match. */
630 'post_url' => self::url( isset( $entry['post_url'] ) ? $entry['post_url'] : '' ),
631
632 /* Catalogue ids for this template's plugin dependencies. Kept because
633 * dependency resolution reads them, and safe to keep because they are ids
634 * that must match the catalogue — not names, slugs or paths. An id the
635 * catalogue does not know is dropped during resolution. */
636 'plugins_id' => self::id_list( isset( $entry['plugins_id'] ) ? $entry['plugins_id'] : array() ),
637 );
638 }
639
640 return $templates;
641 }
642
643 /**
644 * Validate the plugin catalogue.
645 *
646 * Only the fields Wdkit_Depends_Installer reads are kept, and `type` is clamped to
647 * plugin|theme so a catalogue entry can never route a theme through the plugin
648 * installer. Entries without a `p_id` are dropped — an entry no template can reference
649 * is dead weight at best.
650 *
651 * @param mixed $value Raw catalogue.
652 * @return array[]
653 */
654 private static function plugin_catalogue( $value ) {
655 if ( ! is_array( $value ) ) {
656 return array();
657 }
658
659 $catalogue = array();
660
661 foreach ( $value as $entry ) {
662 $entry = is_object( $entry ) ? (array) $entry : $entry;
663
664 if ( ! is_array( $entry ) || ! isset( $entry['p_id'] ) || ! is_numeric( $entry['p_id'] ) ) {
665 continue;
666 }
667
668 $type = isset( $entry['type'] ) ? self::text( $entry['type'], 12 ) : 'plugin';
669
670 $catalogue[] = array(
671 'p_id' => (int) $entry['p_id'],
672 'plugin_name' => self::text( isset( $entry['plugin_name'] ) ? $entry['plugin_name'] : '', 200 ),
673 'plugin_slug' => self::plugin_path( isset( $entry['plugin_slug'] ) ? $entry['plugin_slug'] : '' ),
674 'original_slug' => self::slug( isset( $entry['original_slug'] ) ? $entry['original_slug'] : '' ),
675 'freepro' => isset( $entry['freepro'] ) && is_numeric( $entry['freepro'] ) ? (int) $entry['freepro'] : 0,
676 'plugin_builder' => self::text( isset( $entry['plugin_builder'] ) ? $entry['plugin_builder'] : '', 60 ),
677 'type' => in_array( $type, array( 'plugin', 'theme' ), true ) ? $type : 'plugin',
678 );
679
680 if ( count( $catalogue ) >= 300 ) {
681 break;
682 }
683 }
684
685 return $catalogue;
686 }
687
688 /**
689 * A plugin slug, as WordPress writes them: `dir/file.php`.
690 *
691 * Rejects traversal and anything that is not a plain relative path, because this value
692 * reaches is_plugin_active() and the installer.
693 *
694 * @param mixed $value Raw slug.
695 * @return string
696 */
697 private static function plugin_path( $value ) {
698 if ( ! is_string( $value ) ) {
699 return '';
700 }
701
702 $value = trim( $value );
703
704 if ( '' === $value || false !== strpos( $value, '..' ) || '/' === $value[0] ) {
705 return '';
706 }
707
708 if ( 1 !== preg_match( '#^[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+\.php$#', $value ) ) {
709 return '';
710 }
711
712 return $value;
713 }
714
715 /**
716 * A wordpress.org-style slug: lowercase letters, digits and dashes.
717 *
718 * @param mixed $value Raw slug.
719 * @return string
720 */
721 private static function slug( $value ) {
722 if ( ! is_string( $value ) ) {
723 return '';
724 }
725
726 $value = strtolower( trim( $value ) );
727
728 return ( 1 === preg_match( '/^[a-z0-9\-]{1,80}$/', $value ) ) ? $value : '';
729 }
730
731 /**
732 * Normalise a list of numeric catalogue ids.
733 *
734 * @param mixed $value Raw list.
735 * @return int[]
736 */
737 private static function id_list( $value ) {
738 if ( ! is_array( $value ) ) {
739 $value = ( is_numeric( $value ) ) ? array( $value ) : array();
740 }
741
742 $ids = array();
743
744 foreach ( $value as $id ) {
745 if ( ! is_numeric( $id ) ) {
746 continue;
747 }
748
749 $ids[] = (int) $id;
750 }
751
752 return array_values( array_unique( $ids ) );
753 }
754
755 /**
756 * Is this context complete enough for an AI import?
757 *
758 * Mirrors the wizard's own gate and the server's requirement: `site_type` and
759 * `site_description` are the two fields AIController@next_check_ai_request rejects an
760 * empty value for.
761 *
762 * @param array $context Normalised context.
763 * @return bool
764 */
765 public static function is_ai_ready( $context ) {
766 return ! empty( $context['site_type'] ) && ! empty( $context['site_description'] );
767 }
768
769 /**
770 * Human-readable reasons a context cannot be run, for the session record.
771 *
772 * @param array $context Normalised context.
773 * @return string[]
774 */
775 public static function validation_errors( $context ) {
776 $errors = array();
777
778 if ( empty( $context['kit_id'] ) ) {
779 $errors[] = __( 'kit_id is required.', 'wdesignkit' );
780 }
781
782 if ( empty( $context['templates'] ) ) {
783 $errors[] = __( 'At least one template is required.', 'wdesignkit' );
784 }
785
786 if ( 'ai_import' === $context['import_type'] && ! self::is_ai_ready( $context ) ) {
787 $errors[] = __( 'An AI import needs both a site type and a description.', 'wdesignkit' );
788 }
789
790 return $errors;
791 }
792 }
793 }
794