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 / utilities / wdesignkit-categories.php

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

459 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abilities: List and manage WDesignKit widget categories.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/list-categories', [
13 'label' => __('List Widget Categories', 'wdesignkit'),
14 'description' => __(
15 'Lists all WDesignKit widget categories. Categories are used to organize widgets in the page builder panel.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 ],
22 'output_schema' => [
23 'type' => 'object',
24 'properties' => [
25 'success' => ['type' => 'boolean'],
26 'total' => ['type' => 'integer'],
27 'categories' => [
28 'type' => 'array',
29 'items' => [
30 'type' => 'object',
31 'properties' => [
32 'name' => ['type' => 'string'],
33 'id' => ['type' => 'string'],
34 'widget_count' => ['type' => 'integer'],
35 'is_protected' => ['type' => 'boolean'],
36 'created_at' => ['type' => ['string', 'null']],
37 ],
38 ],
39 ],
40 ],
41 ],
42 'execute_callback' => 'wdesignkit_mcp_list_categories',
43 'permission_callback' => 'wdesignkit_mcp_permission_callback',
44 'meta' => [
45 'public' => true,
46 'show_in_rest' => true,
47 'mcp' => ['public' => true],
48 'annotations' => [
49 'instructions' => implode("\n", [
50 'Lists all widget categories used in WDesignKit.',
51 '"WDesignKit" is the default category and always present.',
52 'Categories appear in the page builder widget panel for organization.',
53 'Each category object includes: name (display name), id (URL-friendly slug), widget_count (number of widgets currently assigned to this category), is_protected (true only for "WDesignKit"), created_at (always null — no timestamp is stored).',
54 'widget_count is computed by scanning widget JSON files on disk and reflects the live state.',
55 ]),
56 'readonly' => true,
57 'destructive' => false,
58 'idempotent' => true,
59 ],
60 ],
61 ]);
62
63 wp_register_ability('wdesignkit/manage-categories', [
64 'label' => __('Manage Widget Categories', 'wdesignkit'),
65 'description' => __(
66 'Add or remove widget categories in WDesignKit. Categories help organize widgets in the page builder panel.',
67 'wdesignkit',
68 ),
69 'category' => 'wdesignkit',
70 'input_schema' => [
71 'type' => 'object',
72 'properties' => [
73 'action' => [
74 'type' => 'string',
75 'description' => 'Action to perform.',
76 'enum' => ['add', 'remove', 'set'],
77 ],
78 'category' => [
79 'type' => 'string',
80 'description' => 'Category name to add or remove. Letters, numbers, spaces, hyphens, underscores, and dots only. Max 64 chars. Used with "add" or "remove" action.',
81 'minLength' => 1,
82 'maxLength' => 64,
83 'pattern' => '^[a-zA-Z0-9 \\-_.]+$',
84 ],
85 'categories' => [
86 'type' => 'array',
87 'items' => [
88 'type' => 'string',
89 'minLength' => 1,
90 'maxLength' => 64,
91 'pattern' => '^[a-zA-Z0-9 \\-_.]+$',
92 ],
93 'description' => 'Full list of categories to set. Used with "set" action. "WDesignKit" is always included.',
94 ],
95 ],
96 'required' => ['action'],
97 'additionalProperties' => false,
98 ],
99 'output_schema' => [
100 'type' => 'object',
101 'properties' => [
102 'success' => ['type' => 'boolean'],
103 'message' => ['type' => 'string'],
104 'categories' => ['type' => 'array'],
105 'removed_categories' => ['type' => 'array', 'items' => ['type' => 'string']],
106 'orphaned_widgets' => ['type' => 'integer'],
107 ],
108 ],
109 'execute_callback' => 'wdesignkit_mcp_manage_categories',
110 'permission_callback' => 'wdesignkit_mcp_permission_callback',
111 'meta' => [
112 'public' => true,
113 'show_in_rest' => true,
114 'mcp' => ['public' => true],
115 'annotations' => [
116 'instructions' => implode("\n", [
117 'Manages widget categories in WDesignKit.',
118 'Category names: letters, numbers, spaces, hyphens, underscores, and dots only. Maximum 64 characters.',
119 'Actions:',
120 '- add: Add a new category (provide "category" param). Duplicate names (case-insensitive) are rejected.',
121 '- remove: Remove a category (provide "category" param). Cannot remove "WDesignKit". Widgets in the removed category are automatically reassigned to "WDesignKit".',
122 '- set: Replace all categories with the provided list (provide "categories" array). "WDesignKit" is always included. Widgets whose category no longer exists are reassigned to "WDesignKit". Response includes removed_categories (names of dropped categories) for audit trail.',
123 ]),
124 'readonly' => false,
125 'destructive' => false,
126 'idempotent' => true,
127 ],
128 ],
129 ]);
130
131 /**
132 * Scan all builder widget folders and return a map of lowercase_category => widget_count.
133 */
134 function wdesignkit_mcp_get_category_widget_counts(): array {
135 if (!defined('WDKIT_BUILDER_PATH')) {
136 return [];
137 }
138
139 $counts = [];
140 $builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
141
142 foreach ($builders as $builder) {
143 $builder_dir = WDKIT_BUILDER_PATH . '/' . $builder;
144 if (!is_dir($builder_dir)) {
145 continue;
146 }
147 $folders = @scandir($builder_dir);
148 if (!is_array($folders)) {
149 continue;
150 }
151 foreach ($folders as $folder) {
152 if (in_array($folder, ['.', '..'], true)) {
153 continue;
154 }
155 $folder_path = $builder_dir . '/' . $folder;
156 if (!is_dir($folder_path)) {
157 continue;
158 }
159 $sub_files = @scandir($folder_path);
160 if (!is_array($sub_files)) {
161 continue;
162 }
163 foreach ($sub_files as $sf) {
164 if (pathinfo($sf, PATHINFO_EXTENSION) !== 'json') {
165 continue;
166 }
167 $raw = @file_get_contents($folder_path . '/' . $sf);
168 if ($raw === false) {
169 break;
170 }
171 $data = json_decode($raw, true);
172 if (json_last_error() !== JSON_ERROR_NONE) {
173 break;
174 }
175 $cat = $data['widget_data']['widgetdata']['category'] ?? '';
176 if ($cat !== '') {
177 $lower = strtolower($cat);
178 $counts[$lower] = ($counts[$lower] ?? 0) + 1;
179 }
180 break;
181 }
182 }
183 }
184
185 return $counts;
186 }
187
188 function wdesignkit_mcp_list_categories(array $input): array {
189 $categories = get_option('wkit_builder', ['WDesignKit']);
190
191 if (!is_array($categories)) {
192 $categories = ['WDesignKit'];
193 }
194
195 if (!in_array('WDesignKit', $categories, true)) {
196 array_unshift($categories, 'WDesignKit');
197 }
198
199 $widget_counts = wdesignkit_mcp_get_category_widget_counts();
200
201 $enriched = [];
202 foreach ($categories as $cat) {
203 $lower = strtolower($cat);
204 $enriched[] = [
205 'name' => $cat,
206 'id' => preg_replace('/[^a-z0-9]+/', '-', $lower),
207 'widget_count' => $widget_counts[$lower] ?? 0,
208 'is_protected' => $cat === 'WDesignKit',
209 'created_at' => null,
210 ];
211 }
212
213 return [
214 'success' => true,
215 'total' => count($enriched),
216 'categories' => $enriched,
217 ];
218 }
219
220 /**
221 * Reassign all widgets that belong to $removed_category to 'WDesignKit' in their JSON config.
222 * Returns the count of widgets updated.
223 */
224 function wdesignkit_mcp_reassign_orphaned_widgets(string $removed_category): int {
225 if (!defined('WDKIT_BUILDER_PATH') || $removed_category === 'WDesignKit') {
226 return 0;
227 }
228
229 include_once ABSPATH . 'wp-admin/includes/file.php';
230 if (!WP_Filesystem()) {
231 return 0;
232 }
233 global $wp_filesystem;
234
235 $builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
236 $updated = 0;
237
238 foreach ($builders as $builder) {
239 $builder_dir = WDKIT_BUILDER_PATH . '/' . $builder;
240 if (!is_dir($builder_dir)) {
241 continue;
242 }
243
244 $folders = @scandir($builder_dir);
245 if (!is_array($folders)) {
246 continue;
247 }
248
249 foreach ($folders as $folder) {
250 if (in_array($folder, ['.', '..'], true)) {
251 continue;
252 }
253 $folder_path = $builder_dir . '/' . $folder;
254 if (!is_dir($folder_path)) {
255 continue;
256 }
257
258 $sub_files = @scandir($folder_path);
259 if (!is_array($sub_files)) {
260 continue;
261 }
262
263 foreach ($sub_files as $sf) {
264 if (pathinfo($sf, PATHINFO_EXTENSION) !== 'json') {
265 continue;
266 }
267 $json_path = $folder_path . '/' . $sf;
268 $raw = @file_get_contents($json_path);
269 if ($raw === false) {
270 break;
271 }
272 $json_data = json_decode($raw, true);
273 if (json_last_error() !== JSON_ERROR_NONE || !isset($json_data['widget_data']['widgetdata'])) {
274 break;
275 }
276
277 $widget_category = $json_data['widget_data']['widgetdata']['category'] ?? '';
278 if (strtolower($widget_category) === strtolower($removed_category)) {
279 $json_data['widget_data']['widgetdata']['category'] = 'WDesignKit';
280 $wp_filesystem->put_contents(
281 $json_path,
282 wp_json_encode($json_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
283 );
284 $updated++;
285 }
286 break;
287 }
288 }
289 }
290
291 return $updated;
292 }
293
294 function wdesignkit_mcp_manage_categories(array $input): array {
295 $action = sanitize_text_field($input['action'] ?? '');
296 $current = get_option('wkit_builder', ['WDesignKit']);
297
298 if (!is_array($current)) {
299 $current = ['WDesignKit'];
300 }
301
302 // Ensure WDesignKit is always present
303 if (!in_array('WDesignKit', $current, true)) {
304 array_unshift($current, 'WDesignKit');
305 }
306
307 switch ($action) {
308 case 'add':
309 $cat = sanitize_text_field($input['category'] ?? '');
310 if (empty($cat)) {
311 return ['success' => false, 'message' => 'Category name is required for "add" action.'];
312 }
313
314 // Server-side validation: mirrors the input_schema pattern + maxLength constraints.
315 if (strlen($cat) > 64) {
316 return ['success' => false, 'message' => 'Category name is too long. Maximum 64 characters.'];
317 }
318 if (!preg_match('/^[a-zA-Z0-9 \-_.]+$/', $cat)) {
319 return ['success' => false, 'message' => 'Category name contains invalid characters. Use letters, numbers, spaces, hyphens, underscores, and dots only.'];
320 }
321
322 // Case-insensitive duplicate check
323 $current_lower = array_map('strtolower', $current);
324 if (in_array(strtolower($cat), $current_lower, true)) {
325 // Find the existing name (preserve original case in the message)
326 $existing_name = $current[array_search(strtolower($cat), $current_lower, true)] ?? $cat;
327 return [
328 'success' => true,
329 'message' => "Category '{$existing_name}' already exists (case-insensitive match).",
330 'categories' => $current,
331 ];
332 }
333
334 $current[] = $cat;
335 update_option('wkit_builder', $current);
336
337 return [
338 'success' => true,
339 'message' => "Category '{$cat}' added.",
340 'categories' => $current,
341 ];
342
343 case 'remove':
344 $cat = sanitize_text_field($input['category'] ?? '');
345 if (empty($cat)) {
346 return ['success' => false, 'message' => 'Category name is required for "remove" action.'];
347 }
348 if (strtolower($cat) === 'wdesignkit') {
349 return ['success' => false, 'message' => 'Cannot remove the default "WDesignKit" category.'];
350 }
351
352 // Case-insensitive search for the category
353 $key = null;
354 foreach ($current as $i => $existing) {
355 if (strtolower($existing) === strtolower($cat)) {
356 $key = $i;
357 $cat = $existing; // use the stored name for cascade
358 break;
359 }
360 }
361
362 if ($key === null) {
363 return [
364 'success' => false,
365 'message' => "Category '{$cat}' not found.",
366 'categories' => $current,
367 ];
368 }
369
370 unset($current[$key]);
371 $current = array_values($current);
372 update_option('wkit_builder', $current);
373
374 // Cascade: reassign widgets that belonged to this category
375 $reassigned = wdesignkit_mcp_reassign_orphaned_widgets($cat);
376
377 return [
378 'success' => true,
379 'message' => "Category '{$cat}' removed." . ($reassigned > 0 ? " {$reassigned} widget(s) reassigned to 'WDesignKit'." : ''),
380 'categories' => $current,
381 'orphaned_widgets' => $reassigned,
382 ];
383
384 case 'set':
385 $cats = $input['categories'] ?? [];
386 if (!is_array($cats)) {
387 return ['success' => false, 'message' => 'Categories must be an array for "set" action.'];
388 }
389
390 $new_list = array_map('sanitize_text_field', $cats);
391 // Per-item validation: mirrors the input_schema item pattern + maxLength constraints.
392 foreach ($new_list as $item) {
393 if (empty($item)) {
394 return ['success' => false, 'message' => 'Category names cannot be empty.'];
395 }
396 if (strlen($item) > 64) {
397 return ['success' => false, 'message' => "Category name '{$item}' is too long. Maximum 64 characters."];
398 }
399 if (!preg_match('/^[a-zA-Z0-9 \-_.]+$/', $item)) {
400 return ['success' => false, 'message' => "Category name '{$item}' contains invalid characters. Use letters, numbers, spaces, hyphens, underscores, and dots only."];
401 }
402 }
403 // Ensure WDesignKit is always present
404 if (!in_array('WDesignKit', $new_list, true)) {
405 array_unshift($new_list, 'WDesignKit');
406 }
407 // Deduplicate (case-insensitive: keep first occurrence)
408 $seen = [];
409 $deduped = [];
410 foreach ($new_list as $c) {
411 $lower = strtolower($c);
412 if (!isset($seen[$lower])) {
413 $seen[$lower] = true;
414 $deduped[] = $c;
415 }
416 }
417 $new_list = $deduped;
418
419 // Idempotency: if the list is identical to the current one, skip the write
420 $sorted_current = $current;
421 $sorted_new = $new_list;
422 sort($sorted_current);
423 sort($sorted_new);
424 if ($sorted_current === $sorted_new) {
425 return [
426 'success' => true,
427 'message' => 'No changes — categories are already set to the provided list.',
428 'categories' => $current,
429 ];
430 }
431
432 // Find removed categories and cascade-reassign their widgets.
433 // Use case-insensitive comparison — a category renamed only in case is NOT removed.
434 $new_list_lower = array_map('strtolower', $new_list);
435 $removed_cats = array_filter($current, function ($cat) use ($new_list_lower) {
436 return !in_array(strtolower($cat), $new_list_lower, true);
437 });
438 $total_reassigned = 0;
439 foreach ($removed_cats as $removed_cat) {
440 if (strtolower($removed_cat) !== 'wdesignkit') {
441 $total_reassigned += wdesignkit_mcp_reassign_orphaned_widgets($removed_cat);
442 }
443 }
444
445 update_option('wkit_builder', $new_list);
446
447 return [
448 'success' => true,
449 'message' => 'Categories updated successfully.' . ($total_reassigned > 0 ? " {$total_reassigned} widget(s) reassigned to 'WDesignKit'." : ''),
450 'categories' => $new_list,
451 'removed_categories' => array_values($removed_cats),
452 'orphaned_widgets' => $total_reassigned,
453 ];
454
455 default:
456 return ['success' => false, 'message' => 'Invalid action. Use "add", "remove", or "set".'];
457 }
458 }
459