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 / abilities / templates / wdesignkit-list-templates.php

wdesignkit-list-templates.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/abilities/templates/wdesignkit-list-templates.php

617 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Browse the current user's saved WDesignKit cloud templates with filter support.
4 *
5 * Also defines auth + cloud-call helpers shared by every template ability.
6 */
7
8 declare(strict_types=1);
9
10 if (!defined('ABSPATH')) {
11 exit();
12 }
13
14 if (!function_exists('wdesignkit_mcp_collect_global_data')) {
15 /**
16 * Build the `global_data` payload for a layout, from this site's Elementor kit.
17 *
18 * Elementor stores a global colour or font as a REFERENCE, not a value — the widget's own
19 * field is left empty and a sibling `__globals__` map holds `globals/colors?id=<_id>`. The
20 * layout therefore carries no colour at all; the definitions travel separately in the
21 * template row's `global_data`, and on import Extract_elementor_global() inlines them and
22 * drops the reference.
23 *
24 * This is the PHP mirror of Get_global_val() in main_save_template.js. It matters because
25 * templates saved through the abilities never carried a `global_data` of their own —
26 * regenerating it from the kit is what gives them one, so the styling survives the import.
27 *
28 * Note for anyone reading the cloud side: update_template writes `global_data` on every
29 * call, substituting an empty array for a parameter the request omits. Any caller of this
30 * function that sends the result to that endpoint must therefore send a real payload, not an
31 * empty one, or it blanks the stored column.
32 *
33 * Reads THIS site's kit, so run from the site the template was authored on; ids that do
34 * not exist here are simply skipped rather than guessed at.
35 *
36 * @param mixed $layout Decoded Elementor layout.
37 * @return array{color:array,typography:array}
38 */
39 function wdesignkit_mcp_collect_global_data($layout): array {
40 $empty = ['color' => [], 'typography' => []];
41
42 if (!is_array($layout)) {
43 return $empty;
44 }
45
46 // Collect every id the layout actually references.
47 $color_ids = [];
48 $typo_ids = [];
49
50 $walk = static function ($node) use (&$walk, &$color_ids, &$typo_ids): void {
51 if (!is_array($node)) {
52 return;
53 }
54
55 if (!empty($node['__globals__']) && is_array($node['__globals__'])) {
56 foreach ($node['__globals__'] as $ref) {
57 if (!is_string($ref) || false === strpos($ref, 'id=')) {
58 continue;
59 }
60
61 $id = substr($ref, strpos($ref, 'id=') + 3);
62 if ('' === $id) {
63 continue;
64 }
65
66 if (false !== strpos($ref, 'globals/colors')) {
67 $color_ids[$id] = true;
68 } elseif (false !== strpos($ref, 'globals/typography')) {
69 $typo_ids[$id] = true;
70 }
71 }
72 }
73
74 foreach ($node as $value) {
75 if (is_array($value)) {
76 $walk($value);
77 }
78 }
79 };
80
81 $walk($layout);
82
83 if (empty($color_ids) && empty($typo_ids)) {
84 return $empty;
85 }
86
87 $kit_id = get_option('elementor_active_kit');
88 if (empty($kit_id)) {
89 return $empty;
90 }
91
92 $kit_meta = get_post_meta($kit_id, '_elementor_page_settings', true);
93 if (!is_array($kit_meta)) {
94 return $empty;
95 }
96
97 $all_colors = array_merge(
98 is_array($kit_meta['system_colors'] ?? null) ? $kit_meta['system_colors'] : [],
99 is_array($kit_meta['custom_colors'] ?? null) ? $kit_meta['custom_colors'] : []
100 );
101 $all_typo = array_merge(
102 is_array($kit_meta['system_typography'] ?? null) ? $kit_meta['system_typography'] : [],
103 is_array($kit_meta['custom_typography'] ?? null) ? $kit_meta['custom_typography'] : []
104 );
105
106 $colors = [];
107 foreach ($all_colors as $entry) {
108 // A colour entry with no value would import as blank, same as no entry at all.
109 if (is_array($entry) && !empty($entry['_id']) && !empty($entry['color']) && isset($color_ids[$entry['_id']])) {
110 $colors[] = $entry;
111 }
112 }
113
114 $typography = [];
115 foreach ($all_typo as $entry) {
116 if (is_array($entry) && !empty($entry['_id']) && isset($typo_ids[$entry['_id']])) {
117 $typography[] = $entry;
118 }
119 }
120
121 return ['color' => $colors, 'typography' => $typography];
122 }
123 }
124
125 if (!function_exists('wdesignkit_mcp_tp_globals')) {
126 /**
127 * Read The Plus Addons' globals out of the active Elementor kit.
128 *
129 * TPAE keeps its global button styles, dimensions, shadows, gradients and animations in
130 * the kit's `_elementor_page_settings`, next to Elementor's own system_colors and
131 * system_typography. Widgets point at an entry by its `_id` through a `tp_global_preset`
132 * setting, so a template saved without these lists carries the reference but not the
133 * definition — and imports with its button styling, radii and shadows dropped.
134 *
135 * Capture lives here rather than in the Save Template UI: templates are saved through the
136 * abilities. The import side (Wdkit_Api_Call::wdkit_merge_tp_globals) merges whatever the
137 * envelope carries.
138 *
139 * @return array Non-empty lists keyed by kit setting name.
140 */
141 function wdesignkit_mcp_tp_globals($layout = null): array {
142 $kit_id = get_option('elementor_active_kit');
143 if (empty($kit_id)) {
144 return [];
145 }
146
147 $kit_meta = get_post_meta($kit_id, '_elementor_page_settings', true);
148 if (!is_array($kit_meta)) {
149 return [];
150 }
151
152 $keys = [
153 'tp_global_button_style_list',
154 'tp_global_dimensions_list',
155 'tp_global_box_shadow_list',
156 'tp_global_gradient_list',
157 'tp_global_gsap_list',
158 'tp_global_scroll_animation_list',
159 'tp_text_global_gsap_list',
160 'tp_image_global_gsap_list',
161 ];
162
163 $collected = [];
164 foreach ($keys as $key) {
165 if (!empty($kit_meta[$key]) && is_array($kit_meta[$key])) {
166 $collected[$key] = $kit_meta[$key];
167 }
168 }
169
170 return is_array($layout) ? wdesignkit_mcp_filter_tp_globals($collected, $layout) : $collected;
171 }
172 }
173
174 if (!function_exists('wdesignkit_mcp_filter_tp_globals')) {
175 /**
176 * Narrow the kit's Plus globals to the ones this layout actually uses.
177 *
178 * Matching is done on the VALUE, not the setting name. TPAE references a global by writing
179 * its `_id` into a widget setting, but the setting is named differently per type — plain
180 * `tp_global_preset` for dimensions, `<control>_tp_bs_global_preset` for box shadows,
181 * `<control>_tp_gg_global_preset` for gradients, and others besides. Matching on the id
182 * itself covers every one of them, and keeps working when TPAE adds another.
183 *
184 * Entries also reference each other — a button style points at dimension and shadow entries —
185 * so the set is expanded until it stops growing. Sending a button without the radius it
186 * depends on would import it half-styled.
187 *
188 * A false positive would need an unrelated setting to equal a 7-character id hash, and would
189 * only mean one extra global travelling, so the scan errs toward matching.
190 *
191 * @param array $all Every list from the kit.
192 * @param array $layout Decoded Elementor layout.
193 * @return array Same shape, holding only the entries this layout reaches.
194 */
195 function wdesignkit_mcp_filter_tp_globals(array $all, array $layout): array {
196 $index = [];
197 foreach ($all as $list_key => $entries) {
198 if (!is_array($entries)) {
199 continue;
200 }
201
202 foreach ($entries as $entry) {
203 if (is_array($entry) && !empty($entry['_id']) && is_string($entry['_id'])) {
204 $index[$entry['_id']] = $list_key;
205 }
206 }
207 }
208
209 if (empty($index)) {
210 return [];
211 }
212
213 $wanted = [];
214
215 $scan = static function ($node) use (&$scan, &$wanted, $index): void {
216 if (!is_array($node)) {
217 return;
218 }
219
220 foreach ($node as $value) {
221 if (is_string($value)) {
222 if (isset($index[$value])) {
223 $wanted[$value] = true;
224 }
225 } elseif (is_array($value)) {
226 $scan($value);
227 }
228 }
229 };
230
231 $scan($layout);
232
233 if (empty($wanted)) {
234 return [];
235 }
236
237 // Follow references between entries until the set stops growing.
238 $scanned = [];
239 do {
240 $before = count($wanted);
241
242 foreach (array_keys($wanted) as $id) {
243 if (isset($scanned[$id])) {
244 continue;
245 }
246
247 $scanned[$id] = true;
248
249 foreach ($all[$index[$id]] as $entry) {
250 if (is_array($entry) && ($entry['_id'] ?? null) === $id) {
251 $scan($entry);
252 break;
253 }
254 }
255 }
256 } while (count($wanted) > $before);
257
258 $filtered = [];
259 foreach ($all as $list_key => $entries) {
260 if (!is_array($entries)) {
261 continue;
262 }
263
264 $used = [];
265 foreach ($entries as $entry) {
266 if (is_array($entry) && !empty($entry['_id']) && isset($wanted[$entry['_id']])) {
267 $used[] = $entry;
268 }
269 }
270
271 if (!empty($used)) {
272 $filtered[$list_key] = $used;
273 }
274 }
275
276 return $filtered;
277 }
278 }
279
280 if (!function_exists('wdesignkit_mcp_tp_global_refs')) {
281 /**
282 * Global colour / font definitions referenced from inside The Plus Addons' globals.
283 *
284 * A Plus global can use an Elementor global of its own — a "Primary Button" entry carries
285 * `__globals__: { text_color: "globals/colors?id=72e09b4", border_color: … }`. Those refs sit
286 * in the kit lists, not in the layout, so wdesignkit_mcp_collect_global_data() never sees
287 * them: the button style would reach the destination kit still pointing at an `_id` that site
288 * has never seen, and render with no text or border colour even though a colour applied
289 * directly on the same page resolved fine.
290 *
291 * The import resolves each reference against the destination kit: an id that site already
292 * defines keeps the reference, so their colour wins; one it does not know is replaced by the
293 * captured value. The user's own global palette is never written to.
294 *
295 * @param array $tp_globals Lists from wdesignkit_mcp_tp_globals().
296 * @return array{color:array,typography:array}
297 */
298 function wdesignkit_mcp_tp_global_refs(array $tp_globals): array {
299 $empty = ['color' => [], 'typography' => []];
300
301 if (empty($tp_globals)) {
302 return $empty;
303 }
304
305 // The lists are a plain nested structure, so the layout scanner works on them as-is.
306 $refs = wdesignkit_mcp_collect_global_data($tp_globals);
307
308 return is_array($refs) ? $refs : $empty;
309 }
310 }
311
312 if (!function_exists('wdesignkit_mcp_template_get_auth')) {
313 /**
314 * Resolve the WDesignKit cloud session for the current request.
315 *
316 * Delegates to wdesignkit_mcp_find_auth_session() (includes/abilities/class-wdk-ability-main.php)
317 * so this and wdesignkit/get-login-status can never disagree about whether a
318 * session exists — they read it through exactly the same lookup.
319 *
320 * @return array{logged_in:bool,email?:string,token?:string,user_id?:string,message?:string}
321 */
322 function wdesignkit_mcp_template_get_auth(): array {
323 $session = wdesignkit_mcp_find_auth_session();
324
325 if (!empty($session['found'])) {
326 $data = $session['data'];
327
328 return [
329 'logged_in' => true,
330 'email' => (string) ($data['user_email'] ?? ''),
331 'token' => (string) $data['token'],
332 'user_id' => (string) ($data['user_id'] ?? $data['id'] ?? ''),
333 ];
334 }
335
336 return [
337 'logged_in' => false,
338 'message' => !empty($session['expired'])
339 ? 'Your WDesignKit cloud session has expired. Log in again with wdesignkit/login, or go to WP Admin → WDesignKit and click Login.'
340 : 'Not logged in to WDesignKit cloud. Go to WP Admin → WDesignKit and click Login. Use wdesignkit/get-login-status to check session state.',
341 ];
342 }
343 }
344
345 if (!function_exists('wdesignkit_mcp_template_cloud_call')) {
346 /**
347 * POST to api.wdesignkit.com/api/wp/{endpoint}.
348 *
349 * $mode 'json' mirrors WDesignKit_Data_Query::get_data (JSON body, used by save/remove/import).
350 * $mode 'form' mirrors the form-encoded preset/AI endpoints (preset/templates/*, ai/template_import).
351 */
352 function wdesignkit_mcp_template_cloud_call(string $endpoint, array $args, string $mode = 'json', int $timeout = 60): array {
353 if (!defined('WDKIT_SERVER_API_URL')) {
354 return ['success' => false, 'message' => 'WDesignKit plugin core not loaded.'];
355 }
356
357 if ($mode === 'json' && class_exists('WDesignKit_Data_Query')) {
358 $response = WDesignKit_Data_Query::get_data($endpoint, $args);
359
360 if (is_wp_error($response)) {
361 return ['success' => false, 'message' => $response->get_error_message()];
362 }
363 if (!is_array($response)) {
364 return ['success' => false, 'message' => 'Unexpected response from WDesignKit cloud.', 'raw' => $response];
365 }
366 return $response;
367 }
368
369 $response = wp_remote_post(
370 WDKIT_SERVER_API_URL . 'api/wp/' . $endpoint,
371 [
372 'method' => 'POST',
373 'body' => $args,
374 'timeout' => $timeout,
375 ]
376 );
377
378 if (is_wp_error($response)) {
379 return ['success' => false, 'message' => $response->get_error_message()];
380 }
381
382 $code = wp_remote_retrieve_response_code($response);
383 $body = wp_remote_retrieve_body($response);
384 $data = json_decode($body, true);
385
386 if (200 !== (int) $code) {
387 // Never include a raw non-JSON body in the return value — it can be a multi-megabyte
388 // HTML error page (e.g. Laravel 500) that bloats the MCP response and crashes schema
389 // validation. Truncate to a short diagnostic snippet instead.
390 $body_summary = is_array($data) ? $data : (
391 is_string($body) && $body !== ''
392 ? '[non-JSON body, ' . strlen($body) . ' bytes: ' . substr(strip_tags($body), 0, 120) . '…]'
393 : null
394 );
395 return [
396 'success' => false,
397 'status' => $code,
398 'message' => 'WDesignKit cloud returned status ' . $code,
399 'body' => $body_summary,
400 ];
401 }
402
403 if (is_array($data)) {
404 return $data;
405 }
406 // HTTP 200 but the body is not JSON (e.g. Laravel exception page returning 200).
407 // Never pass the raw body through — it can be several megabytes of HTML and will
408 // crash MCP schema validation. Truncate to a short diagnostic snippet instead.
409 return [
410 'success' => false,
411 'message' => 'Non-JSON response from cloud.',
412 'raw' => is_string($body) && $body !== ''
413 ? '[non-JSON body, ' . strlen($body) . ' bytes: ' . substr(strip_tags($body), 0, 120) . '…]'
414 : '',
415 ];
416 }
417 }
418
419 if (!function_exists('wdesignkit_mcp_ensure_object')) {
420 /**
421 * Ensure $data is returned as an associative PHP array (JSON object).
422 *
423 * Some cloud endpoints return a JSON array ([...]) instead of a JSON object ({...}).
424 * MCP output schemas declare response fields as type "object"; passing a JSON array
425 * fails the client-side schema validation with "is not of type object".
426 * This helper wraps indexed arrays in {"data": [...]} so the envelope is always an object.
427 *
428 * @param mixed $data Decoded JSON value (array, null, or scalar).
429 * @param string $body Raw response body — used as fallback when $data is not an array.
430 * @return array Always an associative PHP array.
431 */
432 function wdesignkit_mcp_ensure_object($data, string $body = ''): array {
433 if (!is_array($data)) {
434 return $body !== '' ? ['raw' => $body] : [];
435 }
436 // Detect a plain indexed (JSON array) response and wrap it so it serialises as {}.
437 if (!empty($data) && array_keys($data) === range(0, count($data) - 1)) {
438 return ['data' => $data];
439 }
440 return $data;
441 }
442 }
443
444 wp_register_ability('wdesignkit/list-templates', [
445 'label' => __('List WDesignKit Templates', 'wdesignkit'),
446 'description' => __(
447 'Browses the current user\'s saved WDesignKit cloud templates with optional filters. Scope is the user\'s OWN saved templates only — this does NOT list the public marketplace / AI Kits. Supports filtering by builder, search keyword, and template type. Use this for "Browse Templates", "Apply Filter", "Update Filter", "Remove Single Filter", and "Clear All Filters" — every filter operation is just a different combination of arguments.',
448 'wdesignkit',
449 ),
450 'category' => 'wdesignkit',
451 'input_schema' => [
452 'type' => 'object',
453 'properties' => [
454 'builder' => [
455 'type' => 'string',
456 'description' => 'Filter by builder. Leave empty to include all.',
457 'enum' => ['', 'elementor', 'gutenberg'],
458 ],
459 'search' => [
460 'type' => 'string',
461 'description' => 'Keyword to search template names. Pass empty string to clear the search filter.',
462 ],
463 'type' => [
464 'type' => 'string',
465 'description' => 'Template type filter (e.g. "page", "section", "block"). Leave empty to include all.',
466 ],
467 'page' => [
468 'type' => 'integer',
469 'description' => 'Page number (1-based). Defaults to 1.',
470 'minimum' => 1,
471 ],
472 'per_page' => [
473 'type' => 'integer',
474 'description' => 'Number of templates per page. Defaults to 12.',
475 'minimum' => 1,
476 'maximum' => 100,
477 ],
478 ],
479 'additionalProperties' => false,
480 ],
481 'output_schema' => [
482 'type' => 'object',
483 'properties' => [
484 'success' => ['type' => 'boolean'],
485 'message' => ['type' => 'string'],
486 'filters' => ['type' => 'object'],
487 'response' => ['type' => ['object', 'array']],
488 ],
489 ],
490 'execute_callback' => 'wdesignkit_mcp_list_templates',
491 'permission_callback' => 'wdesignkit_mcp_permission_callback',
492 'meta' => [
493 'public' => true,
494 'show_in_rest' => true,
495 'mcp' => ['public' => true],
496 'annotations' => [
497 'instructions' => implode("\n", [
498 'Lists the user\'s saved WDesignKit cloud templates.',
499 'Scope: the user\'s OWN saved templates only — NOT the public marketplace / AI Kits.',
500 'Requires WDesignKit cloud login (use wdesignkit/get-login-status to verify).',
501 'Filters map directly to the ClickUp Template-ability filter actions:',
502 '- Apply Filter: include the desired keys (builder, type, search).',
503 '- Update Filter: re-call with the new values.',
504 '- Remove Single Filter: re-call omitting (or passing empty string for) that key.',
505 '- Clear All Filters: call with no arguments.',
506 'Returns the raw cloud response under the "response" key, plus the active filters under "filters" for confirmation.',
507 ]),
508 'readonly' => true,
509 'destructive' => false,
510 'idempotent' => true,
511 ],
512 ],
513 ]);
514
515 function wdesignkit_mcp_list_templates(array $input): array {
516 set_time_limit(90);
517
518 $auth = wdesignkit_mcp_template_get_auth();
519 if (empty($auth['logged_in'])) {
520 return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in.'];
521 }
522
523 $builder = isset($input['builder']) ? sanitize_text_field((string) $input['builder']) : '';
524 $search = isset($input['search']) ? sanitize_text_field((string) $input['search']) : '';
525 $type = isset($input['type']) ? sanitize_text_field((string) $input['type']) : '';
526 $page = max(1, (int) ($input['page'] ?? 1));
527 $per_page = min(100, max(1, (int) ($input['per_page'] ?? 12)));
528
529 // Root-cause fix: the kit_template endpoint requires a kit_id (the user's library bundle ID)
530 // which is not stored in the local session. The "My Uploads" page in the WDesignKit admin
531 // gets its template list from get_user_info, which returns the user's saved templates in the
532 // 'template' key of the cloud response. Replicate that approach here and apply all filters
533 // locally so no separate kit_id lookup is required.
534 $user_data = wdesignkit_mcp_template_cloud_call('get_user_info', [
535 'token' => $auth['token'],
536 'builder' => '',
537 'site_url' => home_url(),
538 ], 'json');
539
540 if (empty($user_data['success'])) {
541 return [
542 'success' => false,
543 'message' => $user_data['message'] ?? 'Failed to fetch user data from cloud.',
544 'response' => wdesignkit_mcp_ensure_object($user_data, ''),
545 ];
546 }
547
548 $templates = $user_data['template'] ?? [];
549 if (!is_array($templates)) {
550 $templates = [];
551 }
552
553 // Map builder string name → numeric post_builder ID stored in the cloud DB.
554 // The kit_plugins_list table stores: elementor=1001, gutenberg=1002, bricks=1003.
555 // post_builder on each template record is the numeric ID, so the comparison must
556 // use the same numeric form — 'elementor' vs '1001' never matches.
557 $builder_id_map = [
558 'elementor' => '1001',
559 'gutenberg' => '1002',
560 'bricks' => '1003',
561 ];
562 $builder_id = $builder !== '' ? ($builder_id_map[$builder] ?? $builder) : '';
563
564 // Apply filters locally
565 if ($builder_id !== '') {
566 $templates = array_values(array_filter($templates, static function ($t) use ($builder_id) {
567 return isset($t['post_builder']) && (string) $t['post_builder'] === $builder_id;
568 }));
569 }
570
571 if ($type !== '') {
572 // The documented filter values are page/section/websitekit, but templates are STORED as
573 // pagetemplate/section/websitekit — save-template-full maps 'page' to 'pagetemplate' before
574 // saving. Comparing the documented value straight against the stored one returned an empty
575 // list with no error, which reads as "no templates exist" (ClickUp 86d41cehy). Map the same
576 // way here so the two vocabularies stay aligned in both directions.
577 $cloud_type_map = ['page' => 'pagetemplate', 'section' => 'section'];
578 $type_stored = $cloud_type_map[$type] ?? $type;
579
580 $templates = array_values(array_filter($templates, static function ($t) use ($type_stored) {
581 return isset($t['type']) && (string) $t['type'] === $type_stored;
582 }));
583 }
584
585 if ($search !== '') {
586 $search_lc = strtolower($search);
587 $templates = array_values(array_filter($templates, static function ($t) use ($search_lc) {
588 $name = strtolower((string) ($t['post_title'] ?? $t['name'] ?? ''));
589 return $name !== '' && strpos($name, $search_lc) !== false;
590 }));
591 }
592
593 $total = count($templates);
594 $total_pages = $per_page > 0 ? (int) ceil($total / $per_page) : 0;
595 $offset = ($page - 1) * $per_page;
596 $paged = array_slice($templates, $offset, $per_page);
597
598 return [
599 'success' => true,
600 'message' => '',
601 'filters' => [
602 'builder' => $builder,
603 'search' => $search,
604 'type' => $type,
605 'page' => $page,
606 'per_page' => $per_page,
607 ],
608 'response' => [
609 'templates' => $paged,
610 'total' => $total,
611 'page' => $page,
612 'per_page' => $per_page,
613 'total_pages' => $total_pages,
614 ],
615 ];
616 }
617