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

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

544 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Kit dependency resolution and installation.
4 *
5 * ── How the browser does it ─────────────────────────────────────────────────
6 *
7 * import_temp_feature.js#get_plugin_data():
8 * 1. `props.wdkit_meta.plugin` is the full plugin catalogue, fetched from the cloud
9 * `meta` endpoint (class-api.php#wdkit_meta_data).
10 * 2. every template in the kit carries `plugins_id[]` — catalogue ids, not names.
11 * 3. each id is looked up in the catalogue to get
12 * {p_id, plugin_name, plugin_slug, original_slug, freepro, plugin_builder, type}.
13 * 4. that resolved list is what `check_plugins_depends` / `install_plugins_depends` act on.
14 *
15 * The important property, which this class preserves: **the browser never sends plugin
16 * names, only ids the catalogue has to recognise.** A dependency that is not in the kit's
17 * own `plugins_id` cannot be installed. That is what stops a payload turning into arbitrary
18 * plugin installation, and it is why resolution lives here rather than accepting a list.
19 *
20 * ── Themes ─────────────────────────────────────────────────────────────────
21 *
22 * Themes are NOT installed through Wdkit_Depends_Installer. The `install_plugins_depends`
23 * handler has a separate branch that either switch_theme()s an already-installed theme or
24 * calls wdkit_install_theme_depends() (which gates on `install_themes` and pulls from
25 * wordpress.org). Both paths are reached here through
26 * Wdkit_Api_Call::wdkit_install_dependency_data(), so that distinction is preserved rather
27 * than regressed.
28 *
29 * @package Wdesignkit
30 * @since 2.6.5
31 */
32
33 if ( ! defined( 'ABSPATH' ) ) {
34 exit;
35 }
36
37 if ( ! class_exists( 'Wdkit_Import_Dependencies' ) ) {
38
39 /**
40 * Dependency resolver and installer.
41 */
42 class Wdkit_Import_Dependencies {
43
44 /**
45 * Where The Plus Addons keeps its enabled-block list.
46 */
47 const BLOCKS_OPTION = 'tpgb_normal_blocks_opts';
48
49 /**
50 * Where the fetched plugin catalogue is cached.
51 *
52 * @var string
53 */
54 const CATALOGUE_TRANSIENT = 'wdkit_import_plugin_catalogue';
55
56 /**
57 * Resolve a kit's dependencies from its own template metadata.
58 *
59 * @param array $templates Kit templates, each optionally carrying `plugins_id`.
60 * @param array $catalogue Plugin catalogue, keyed however the cloud returns it.
61 * @return array[] Resolved dependency records, unique by p_id.
62 */
63 /**
64 * The plugin catalogue, fetched from the cloud when nobody supplied one.
65 *
66 * The wizard passes this in: the browser already holds `wdkit_meta.builder` and
67 * `wdkit_meta.plugin` from the meta call it makes on load. Nothing else does — a
68 * headless or site-side import arrives with an empty catalogue, and an empty catalogue
69 * makes resolve() return nothing at all.
70 *
71 * That was not a small gap. Every template in a kit lists its dependencies as
72 * catalogue ids (`plugins_id: [1003, 1005]`), and with no catalogue to look them up in,
73 * a remote import installed only the two descriptors that happen to be hardcoded — the
74 * theme and Nexter Extension. An Elementor kit imported its pages and then rendered
75 * them blank, because Elementor itself was never installed.
76 *
77 * Cached for a day: the catalogue is a slow-moving list and an import should not spend
78 * a round trip on it per run.
79 *
80 * @return array Catalogue entries, or [] when the cloud could not be reached.
81 */
82 public static function catalogue() {
83 $cached = get_transient( self::CATALOGUE_TRANSIENT );
84
85 if ( is_array( $cached ) ) {
86 return $cached;
87 }
88
89 if ( ! class_exists( 'WDesignKit_Data_Query' ) ) {
90 return array();
91 }
92
93 $response = WDesignKit_Data_Query::get_data( 'meta', array( 'type' => '' ) );
94
95 if ( is_wp_error( $response ) || ! is_array( $response ) ) {
96 return array();
97 }
98
99 /* The response nests under `data` for this endpoint, and the two lists that matter
100 * are `plugin` (addons a template needs) and `builder` (Elementor, Gutenberg…).
101 * Both are indexed by the same p_id space, so they merge into one catalogue. */
102 $data = ! empty( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : $response;
103
104 $catalogue = array();
105
106 foreach ( array( 'plugin', 'builder' ) as $key ) {
107 if ( ! empty( $data[ $key ] ) && is_array( $data[ $key ] ) ) {
108 foreach ( $data[ $key ] as $entry ) {
109 if ( is_array( $entry ) ) {
110 $catalogue[] = $entry;
111 }
112 }
113 }
114 }
115
116 if ( empty( $catalogue ) ) {
117 return array();
118 }
119
120 set_transient( self::CATALOGUE_TRANSIENT, $catalogue, DAY_IN_SECONDS );
121
122 return $catalogue;
123 }
124
125 /**
126 * The catalogue entry for one builder, by its p_id.
127 *
128 * Used to add the kit's own builder as a dependency. A kit's templates list the addons
129 * they use but not the builder they are written for — the wizard never had to care
130 * because the builder was already active in the browser it was running in.
131 *
132 * @param array $catalogue Catalogue entries.
133 * @param mixed $p_id Builder id.
134 * @return array|null
135 */
136 public static function builder_entry( $catalogue, $p_id ) {
137 $want = (string) $p_id;
138
139 foreach ( (array) $catalogue as $entry ) {
140 if ( is_array( $entry ) && isset( $entry['p_id'] ) && (string) $entry['p_id'] === $want ) {
141 return $entry;
142 }
143 }
144
145 return null;
146 }
147
148 public static function resolve( $templates, $catalogue ) {
149 $by_id = self::index_catalogue( $catalogue );
150
151 if ( empty( $by_id ) || ! is_array( $templates ) ) {
152 return array();
153 }
154
155 $resolved = array();
156
157 foreach ( $templates as $template ) {
158 if ( ! is_array( $template ) || empty( $template['plugins_id'] ) ) {
159 continue;
160 }
161
162 $ids = is_array( $template['plugins_id'] ) ? $template['plugins_id'] : array( $template['plugins_id'] );
163
164 foreach ( $ids as $p_id ) {
165 $key = (string) $p_id;
166
167 /* Unknown id → not a dependency of this kit as far as the catalogue is
168 * concerned. Dropped rather than guessed at: an id we cannot resolve is
169 * exactly the case where installing something would be a mistake. */
170 if ( ! isset( $by_id[ $key ] ) || isset( $resolved[ $key ] ) ) {
171 continue;
172 }
173
174 $resolved[ $key ] = $by_id[ $key ];
175 }
176 }
177
178 return array_values( $resolved );
179 }
180
181 /**
182 * Index the catalogue by p_id, keeping only the fields the installer reads.
183 *
184 * @param array $catalogue Raw catalogue.
185 * @return array<string,array>
186 */
187 private static function index_catalogue( $catalogue ) {
188 $by_id = array();
189
190 if ( ! is_array( $catalogue ) ) {
191 return $by_id;
192 }
193
194 foreach ( $catalogue as $entry ) {
195 $entry = is_object( $entry ) ? (array) $entry : $entry;
196
197 if ( ! is_array( $entry ) || ! isset( $entry['p_id'] ) ) {
198 continue;
199 }
200
201 $type = isset( $entry['type'] ) ? (string) $entry['type'] : 'plugin';
202
203 $by_id[ (string) $entry['p_id'] ] = array(
204 'p_id' => $entry['p_id'],
205 'plugin_name' => isset( $entry['plugin_name'] ) ? (string) $entry['plugin_name'] : '',
206 'plugin_slug' => isset( $entry['plugin_slug'] ) ? (string) $entry['plugin_slug'] : '',
207 'original_slug' => isset( $entry['original_slug'] ) ? (string) $entry['original_slug'] : '',
208 'freepro' => isset( $entry['freepro'] ) ? $entry['freepro'] : 0,
209 'plugin_builder' => isset( $entry['plugin_builder'] ) ? (string) $entry['plugin_builder'] : '',
210 'type' => in_array( $type, array( 'plugin', 'theme' ), true ) ? $type : 'plugin',
211 );
212 }
213
214 return $by_id;
215 }
216
217 /**
218 * Is this dependency already present and active?
219 *
220 * Mirrors what `check_plugins_depends` reports, so an install is not attempted for
221 * something the site already has.
222 *
223 * @param array $dependency Resolved dependency record.
224 * @return bool
225 */
226 public static function is_satisfied( $dependency ) {
227 $type = ! empty( $dependency['type'] ) ? $dependency['type'] : 'plugin';
228
229 if ( 'theme' === $type ) {
230 $slug = ! empty( $dependency['original_slug'] ) ? $dependency['original_slug'] : '';
231
232 return '' !== $slug && get_stylesheet() === $slug;
233 }
234
235 $slug = ! empty( $dependency['plugin_slug'] ) ? $dependency['plugin_slug'] : '';
236
237 if ( '' === $slug ) {
238 return false;
239 }
240
241 if ( ! function_exists( 'is_plugin_active' ) ) {
242 require_once ABSPATH . 'wp-admin/includes/plugin.php';
243 }
244
245 return is_plugin_active( $slug );
246 }
247
248 /**
249 * Install / activate a resolved dependency list.
250 *
251 * Every unit is independent — a Pro plugin the licence cannot fetch is reported and
252 * the rest of the kit still installs. That matches the browser, which surfaces
253 * `pro_plugin` as a decision rather than failing the import.
254 *
255 * @param array[] $dependencies Resolved dependency records.
256 * @return array{installed:array,already_installed:array,skipped:array,failed:array,rows:array}
257 */
258 public static function install( $dependencies ) {
259 $result = array(
260 'installed' => array(),
261 'already_installed' => array(),
262 'skipped' => array(),
263 'failed' => array(),
264
265 /* One entry per dependency in the shape the wizard's progress list renders. */
266 'rows' => array(),
267 );
268
269 if ( empty( $dependencies ) || ! is_array( $dependencies ) ) {
270 return $result;
271 }
272
273 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_install_dependency_data' ) ) {
274 throw new Wdkit_Import_Non_Retryable_Exception(
275 __( 'Dependency installer is unavailable.', 'wdesignkit' ),
276 'installer_missing',
277 Wdkit_Import_Runner::STAGE_DEPENDENCIES
278 );
279 }
280
281 $api = Wdkit_Api_Call::get_instance();
282
283 /* The wizard renders one row per dependency using the catalogue's own fields —
284 * plugin_name, freepro, type — and a status from its own vocabulary. The keys below
285 * only carried the slug, which is why the progress list read
286 * "undefined Free undefined not installed". Recorded alongside, not instead of, so
287 * every existing reader of installed/already_installed/skipped/failed is unaffected. */
288 $row = function ( $dependency, $label, $status ) use ( &$result ) {
289 $result['rows'][] = array(
290 'plugin_name' => ! empty( $dependency['plugin_name'] ) ? $dependency['plugin_name'] : $label,
291 'freepro' => isset( $dependency['freepro'] ) ? (string) $dependency['freepro'] : '0',
292 'type' => ! empty( $dependency['type'] ) ? $dependency['type'] : 'plugin',
293 'status' => $status,
294 );
295 };
296
297 foreach ( $dependencies as $dependency ) {
298 $label = ! empty( $dependency['original_slug'] ) ? $dependency['original_slug'] : ( isset( $dependency['p_id'] ) ? (string) $dependency['p_id'] : '' );
299
300 if ( self::is_satisfied( $dependency ) ) {
301 $result['already_installed'][] = $label;
302
303 /* 'active' is the wizard's word for "present and usable". */
304 $row( $dependency, $label, 'active' );
305 continue;
306 }
307
308 /* A Pro dependency needs a licence the site may not have. The installer itself
309 * reports that; capability is checked here so a runner without rights records a
310 * skip instead of a hard failure. */
311 if ( 'theme' === $dependency['type'] && ! current_user_can( 'install_themes' ) ) {
312 $result['skipped'][] = array(
313 'slug' => $label,
314 'reason' => 'missing_install_themes_capability',
315 );
316
317 $row( $dependency, $label, 'unavailable' );
318 continue;
319 }
320
321 if ( 'plugin' === $dependency['type'] && ! current_user_can( 'install_plugins' ) ) {
322 $result['skipped'][] = array(
323 'slug' => $label,
324 'reason' => 'missing_install_plugins_capability',
325 );
326
327 $row( $dependency, $label, 'unavailable' );
328 continue;
329 }
330
331 try {
332 $response = $api->wdkit_install_dependency_data( $dependency );
333
334 if ( ! empty( $response['success'] ) ) {
335 $result['installed'][] = $label;
336
337 $row( $dependency, $label, 'active' );
338 } else {
339 $result['failed'][] = array(
340 'slug' => $label,
341 'message' => isset( $response['description'] ) ? (string) $response['description'] : '',
342 );
343
344 $row( $dependency, $label, 'fail' );
345 }
346 } catch ( Throwable $e ) {
347 $result['failed'][] = array(
348 'slug' => $label,
349 'message' => $e->getMessage(),
350 );
351
352 $row( $dependency, $label, 'fail' );
353 }
354 }
355
356 /* Plugins just activated (Rank Math, WooCommerce, Elementor) arm a one-shot "go to
357 * the setup wizard" redirect for the next admin request - which is the wizard's next
358 * stage call, answered with the plugin's HTML instead of JSON, and the import hung
359 * there (ClickUp 14ynqxz2tpp). The legacy installer already disarmed these; this
360 * route never did. */
361 if ( ! empty( $result['installed'] ) && method_exists( 'Wdkit_Api_Call', 'wdkit_clear_activation_redirects' ) ) {
362 Wdkit_Api_Call::wdkit_clear_activation_redirects();
363 }
364
365 return $result;
366 }
367
368 /**
369 * Widget and extension names used by imported kit content.
370 *
371 * Templates carry a `widget_list` (the same field wdkit_create_full_site collects for
372 * its `enable_widgets` step). Derived from content, never from a payload.
373 *
374 * @param array[] $decoded_templates Decoded template payloads.
375 * @return array{widgets:string[],extensions:string[]}
376 */
377 public static function collect_widgets( $decoded_templates ) {
378 $widgets = array();
379 $extensions = array();
380
381 foreach ( (array) $decoded_templates as $decoded ) {
382 if ( ! is_array( $decoded ) ) {
383 continue;
384 }
385
386 if ( ! empty( $decoded['widget_list'] ) && is_array( $decoded['widget_list'] ) ) {
387 $widgets = array_merge( $widgets, $decoded['widget_list'] );
388 }
389
390 if ( ! empty( $decoded['extensions_list'] ) && is_array( $decoded['extensions_list'] ) ) {
391 $extensions = array_merge( $extensions, $decoded['extensions_list'] );
392 }
393 }
394
395 return array(
396 'widgets' => array_values( array_unique( array_filter( $widgets, 'is_string' ) ) ),
397 'extensions' => array_values( array_unique( array_filter( $extensions, 'is_string' ) ) ),
398 );
399 }
400
401 /**
402 * Enable the collected widgets through the existing handler's logic.
403 *
404 * @param array $widgets Widget names.
405 * @param array $extensions Extension names.
406 * @return array Response from Wdkit_Api_Call::wdkit_enable_widgets_data().
407 */
408 public static function enable_widgets( $widgets, $extensions = array() ) {
409 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_enable_widgets_data' ) ) {
410 return array( 'success' => false );
411 }
412
413 $response = Wdkit_Api_Call::get_instance()->wdkit_enable_widgets_data( $widgets, $extensions );
414
415 /* Every caller reads $response['success']. A transport failure hands back a
416 * WP_Error instead, and in PHP 8 even isset()/empty() on an object that is not
417 * ArrayAccess is a fatal "Cannot use object of type WP_Error as array" - which
418 * is how a timed-out cloud call took down the whole content stage rather than
419 * leaving one group of widgets unenabled. Normalised here, once, so no caller
420 * has to guard. */
421 return is_wp_error( $response )
422 ? array( 'success' => false, 'message' => $response->get_error_message() )
423 : $response;
424 }
425 /**
426 * Turn on the Plus Addons blocks a Gutenberg kit actually uses.
427 *
428 * The Plus Addons ships most blocks DISABLED and enables them on demand. A block that is
429 * off is not registered, so WordPress cannot render it, cannot regenerate its block id and
430 * cannot generate its CSS - the markup survives, but it falls back to bare theme styling.
431 *
432 * The browser handles this with set_nexter_widgets(): regex the block names out of the
433 * template markup and POST them to `scan_nexter_widgets`, which applies
434 * `nexter_block_list_merge`. The runner only ever implemented the ELEMENTOR half of the
435 * same job - collect_widgets()/enable_widgets(), which walk `widget_list` and `widgetType`,
436 * both Elementor-only concepts. So on a Gutenberg kit nothing was enabled at all: this
437 * kit uses 34 blocks, 16 of them were off, and every one of those rendered unstyled -
438 * including tp-heading-title, which is the page's main headline.
439 *
440 * @since 2.6.5
441 *
442 * @param array $names Block names, with or without the `tpgb/` prefix.
443 * @return array{success:bool,requested:int,added:array,enabled:int}
444 */
445 public static function enable_blocks( $names ) {
446 $clean = array();
447
448 foreach ( (array) $names as $name ) {
449 if ( ! is_string( $name ) || '' === $name ) {
450 continue;
451 }
452
453 /* The filter stores bare names - `tp-heading`, not `tpgb/tp-heading`. */
454 $name = sanitize_text_field( str_replace( 'tpgb/', '', $name ) );
455
456 if ( '' !== $name && 0 === strpos( $name, 'tp-' ) ) {
457 $clean[ $name ] = true;
458 }
459 }
460
461 $clean = array_keys( $clean );
462
463 if ( empty( $clean ) ) {
464 return array(
465 'success' => false,
466 'requested' => 0,
467 'added' => array(),
468 'enabled' => 0,
469 );
470 }
471
472 $before = self::enabled_block_list();
473
474 /* The Plus Addons' own handler, when it is listening - it also flips on the Google
475 * Maps connection when tp-google-map is among the names, which duplicating the option
476 * write here would miss. */
477 if ( has_filter( 'nexter_block_list_merge' ) ) {
478 apply_filters( 'nexter_block_list_merge', $clean );
479 } else {
480 /* Registered on an admin hook, so it is absent under WP-CLI and cron. Same merge,
481 * same option, so an import driven from either still enables its blocks. */
482 $opts = get_option( self::BLOCKS_OPTION );
483
484 if ( ! is_array( $opts ) ) {
485 $opts = array();
486 }
487
488 if ( ! isset( $opts['enable_normal_blocks'] ) || ! is_array( $opts['enable_normal_blocks'] ) ) {
489 $opts['enable_normal_blocks'] = array();
490 }
491
492 $opts['enable_normal_blocks'] = array_values( array_unique( array_merge( $opts['enable_normal_blocks'], $clean ) ) );
493
494 update_option( self::BLOCKS_OPTION, $opts );
495 }
496
497 $after = self::enabled_block_list();
498
499 return array(
500 'success' => true,
501 'requested' => count( $clean ),
502 'added' => array_values( array_diff( $after, $before ) ),
503 'enabled' => count( $after ),
504 );
505 }
506
507 /**
508 * Block names currently switched on.
509 *
510 * @return string[]
511 */
512 private static function enabled_block_list() {
513 $opts = get_option( self::BLOCKS_OPTION );
514
515 if ( is_string( $opts ) ) {
516 $opts = json_decode( $opts, true );
517 }
518
519 return ( is_array( $opts ) && ! empty( $opts['enable_normal_blocks'] ) && is_array( $opts['enable_normal_blocks'] ) )
520 ? array_values( $opts['enable_normal_blocks'] )
521 : array();
522 }
523
524 /**
525 * Every `wp:tpgb/*` block name in a piece of block markup.
526 *
527 * Same scan set_nexter_widgets() does, on the same input.
528 *
529 * @param string $content Block markup.
530 * @return string[]
531 */
532 public static function block_names_in( $content ) {
533 if ( ! is_string( $content ) || '' === $content ) {
534 return array();
535 }
536
537 preg_match_all( '/<!--\s+wp:(tpgb\/[a-z0-9-]+)/', $content, $matches );
538
539 return ! empty( $matches[1] ) ? array_values( array_unique( $matches[1] ) ) : array();
540 }
541
542 }
543 }
544