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 / widgets / wdesignkit-update-widget.php

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

511 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Update an existing WDesignKit widget's code or metadata.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/update-widget', [
13 'label' => __('Update WDesignKit Widget', 'wdesignkit'),
14 'description' => __(
15 'Updates an existing widget\'s PHP code, CSS styles, JS scripts, or JSON config metadata. Each code field is a FULL file replacement. Always use get-widget first to read the current code, then provide the complete updated content.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'builder' => [
23 'type' => 'string',
24 'description' => 'Builder type the widget belongs to.',
25 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'],
26 ],
27 'folder' => [
28 'type' => 'string',
29 'description' => 'Widget folder name — from list-widgets or the folder returned by create-widget.',
30 ],
31 'php_code' => [
32 'type' => 'string',
33 'description' => 'Complete replacement PHP file content. REPLACES the ENTIRE PHP file. Always call get-widget first and base your edit on the full current content.',
34 ],
35 'css_code' => [
36 'type' => 'string',
37 'description' => 'Complete replacement CSS file content. REPLACES the ENTIRE CSS file.',
38 ],
39 'js_code' => [
40 'type' => 'string',
41 'description' => 'Complete replacement JavaScript file content. REPLACES the ENTIRE JS file.',
42 ],
43 'name' => [
44 'type' => 'string',
45 'description' => 'Updated widget display name. Letters, numbers, spaces, hyphens, underscores only. Max 64 chars. Also updates the JSON config and the PHP get_title()/get_label() return value.',
46 'minLength' => 1,
47 'maxLength' => 64,
48 'pattern' => '^[a-zA-Z0-9 \\-_]+$',
49 ],
50 'description' => [
51 'type' => 'string',
52 'description' => 'Updated widget description. Updates the JSON config and the PHP header comment (if present).',
53 ],
54 'version' => [
55 'type' => 'string',
56 'description' => 'Updated version number in semver format (e.g. "1.2.0"). Updates the JSON config and the PHP asset enqueue version strings.',
57 'pattern' => '^\d+\.\d+\.\d+$',
58 ],
59 'helper_link' => [
60 'type' => 'string',
61 'description' => 'Optional documentation URL for the widget. Updates widgetdata.helper_link in the JSON config. Pass an empty string to clear it.',
62 'format' => 'uri',
63 'maxLength' => 512,
64 ],
65 'validate' => [
66 'type' => 'boolean',
67 'description' => 'When true, runs wdesignkit/validate-widget inline first and refuses the update if validation checks fail.',
68 ],
69 ],
70 'required' => ['builder', 'folder'],
71 'additionalProperties' => false,
72 ],
73 'output_schema' => [
74 'type' => 'object',
75 'properties' => [
76 'success' => ['type' => 'boolean'],
77 'message' => ['type' => 'string'],
78 'files_updated' => ['type' => 'array', 'items' => ['type' => 'string']],
79 'widget_name' => ['type' => 'string'],
80 'widget_id' => ['type' => 'string'],
81 ],
82 ],
83 'execute_callback' => 'wdesignkit_mcp_update_widget',
84 'permission_callback' => 'wdesignkit_mcp_permission_callback',
85 'meta' => [
86 'public' => true,
87 'show_in_rest' => true,
88 'mcp' => ['public' => true],
89 'annotations' => [
90 'instructions' => implode("\n", [
91 '## wdesignkit/update-widget — Usage Rules',
92 '',
93 '### Golden rule',
94 'ALWAYS call wdesignkit/get-widget FIRST to read the current file contents.',
95 'Then construct your update from that complete current content.',
96 '',
97 '### Code fields are FULL replacements',
98 '- php_code, css_code, js_code each REPLACE the ENTIRE file.',
99 '- A partial snippet will overwrite and destroy the rest of the file.',
100 '- Fields not provided in the input are left completely unchanged.',
101 '',
102 '### Metadata fields update only the JSON config',
103 '- name: also updates get_title() in Elementor PHP and get_label() in Bricks PHP.',
104 '- description: updates JSON config only.',
105 '- version: updates JSON config only.',
106 '- helper_link: updates JSON config only. Optional — pass empty string to clear it.',
107 '',
108 '### What never changes',
109 '- The widget folder name never changes on update.',
110 '- The widget_id never changes on update.',
111 '- Files not provided in the input are never touched.',
112 '',
113 '### Idempotency',
114 '- Files are only written if the new content differs from the existing content.',
115 '- If nothing changes, success:true is returned with an empty files_updated array.',
116 '',
117 '### Workflow',
118 '1. wdesignkit/list-widgets → find builder + folder',
119 '2. wdesignkit/get-widget → read current code',
120 '3. wdesignkit/update-widget → provide full updated file content',
121 '4. wdesignkit/sync-widget-code → run after raw php_code updates to keep section_data in sync',
122 ]),
123 'readonly' => false,
124 'destructive' => true,
125 'idempotent' => true,
126 ],
127 ],
128 ]);
129
130 function wdesignkit_mcp_update_widget(array $input): array {
131 if (!defined('WDKIT_BUILDER_PATH')) {
132 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
133 }
134
135 $builder = sanitize_text_field($input['builder'] ?? '');
136 $folder = sanitize_file_name($input['folder'] ?? '');
137
138 if (empty($builder) || empty($folder)) {
139 return ['success' => false, 'message' => 'Both builder and folder are required.'];
140 }
141
142 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
143 if (!in_array($builder, $allowed_builders, true)) {
144 return ['success' => false, 'message' => 'Invalid builder type.'];
145 }
146
147 $widget_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $folder;
148
149 if (!is_dir($widget_dir)) {
150 return ['success' => false, 'message' => "Widget folder not found: {$builder}/{$folder}"];
151 }
152
153 // Realpath validation — prevent path traversal
154 $real_widget = realpath($widget_dir);
155 $real_base = realpath(WDKIT_BUILDER_PATH);
156 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
157 return ['success' => false, 'message' => 'Invalid widget path.'];
158 }
159
160 include_once ABSPATH . 'wp-admin/includes/file.php';
161 if (!WP_Filesystem()) {
162 return ['success' => false, 'message' => 'Could not initialize filesystem.'];
163 }
164 global $wp_filesystem;
165
166 // --- Locate existing files ---
167 $files = @scandir($widget_dir);
168 if (!is_array($files)) {
169 return ['success' => false, 'message' => "Cannot read widget folder: {$builder}/{$folder}"];
170 }
171 $files = array_diff($files, ['.', '..']);
172
173 $file_base = null;
174 $json_file = null;
175
176 foreach ($files as $f) {
177 if (pathinfo($f, PATHINFO_EXTENSION) === 'json') {
178 $json_file = $widget_dir . '/' . $f;
179 $file_base = $widget_dir . '/' . pathinfo($f, PATHINFO_FILENAME);
180 break;
181 }
182 }
183
184 if (!$file_base) {
185 // Fallback: derive file base from folder name (hyphens → underscores matches creation convention)
186 $file_base = $widget_dir . '/' . str_replace('-', '_', $folder);
187 }
188
189 // --- Validate and sanitize optional name ---
190 $new_name = '';
191 if (isset($input['name']) && $input['name'] !== '') {
192 $raw_name = preg_replace('/[^a-zA-Z0-9 \-_]/', '', sanitize_text_field($input['name']));
193 $raw_name = trim($raw_name);
194 if (empty($raw_name)) {
195 return ['success' => false, 'message' => 'Widget name contains only invalid characters.'];
196 }
197 if (strlen($raw_name) > 64) {
198 return ['success' => false, 'message' => 'Widget name is too long. Maximum 64 characters.'];
199 }
200 $new_name = $raw_name;
201 }
202
203 // --- Validate version if provided ---
204 if (!empty($input['version']) && !preg_match('/^\d+\.\d+\.\d+$/', $input['version'])) {
205 return ['success' => false, 'message' => 'Invalid version format. Use semver like "1.2.0".'];
206 }
207
208 // --- Pre-write code validation if requested ---
209 if (!empty($input['validate'])) {
210 if (!function_exists('wdesignkit_mcp_validate_widget')) {
211 $vw_path = __DIR__ . '/wdesignkit-validate-widget.php';
212 if (file_exists($vw_path)) {
213 require_once $vw_path;
214 }
215 }
216 if (function_exists('wdesignkit_mcp_validate_widget')) {
217 $val_res = wdesignkit_mcp_validate_widget([
218 'php_code' => $input['php_code'] ?? '',
219 'css_code' => $input['css_code'] ?? '',
220 'js_code' => $input['js_code'] ?? '',
221 'builder' => $builder,
222 'folder' => $folder,
223 'name' => $new_name ?: $folder,
224 ]);
225 if (empty($val_res['valid'])) {
226 return [
227 'success' => false,
228 'message' => 'Validation failed: ' . ($val_res['message'] ?? 'Widget code failed validation checks.'),
229 'checks' => $val_res['checks'] ?? [],
230 ];
231 }
232 }
233 }
234
235 // --- Tracking arrays ---
236 $files_updated = [];
237 $write_errors = [];
238
239 // Write only when content has actually changed (idempotent)
240 $maybe_write = function(string $path, string $new_content) use ($wp_filesystem, &$files_updated, &$write_errors): void {
241 $existing = @file_get_contents($path);
242 if ($existing === $new_content) {
243 return;
244 }
245 if ($wp_filesystem->put_contents($path, $new_content)) {
246 $files_updated[] = basename($path);
247 } else {
248 $write_errors[] = strtoupper(pathinfo($path, PATHINFO_EXTENSION));
249 }
250 };
251
252 // --- Update code files (only fields explicitly provided) ---
253 // Site-level opt-out for generated widget PHP (ClickUp 86d41zavc). Gating create-widget alone
254 // would be theatre: an operator blocked from creating a widget could simply overwrite an
255 // existing widget's PHP here instead — same capability, same sink, same result. Scoped to the
256 // php_code branch on purpose, so a locked-down site can still update css/js/json.
257 if (isset($input['php_code']) && $input['php_code'] !== ''
258 && function_exists('wdesignkit_widget_php_write_allowed') && !wdesignkit_widget_php_write_allowed()) {
259 return [
260 'success' => false,
261 'message' => 'php_code was not written: generated widget PHP writes are disabled on this site via the wdesignkit_allow_widget_php_write filter. Re-send without php_code to update css_code / js_code / metadata only.',
262 ];
263 }
264
265 if (isset($input['php_code']) && $input['php_code'] !== '') {
266 // Strip any namespace declaration — the widget loader instantiates the bare
267 // global class name, so namespaced PHP would fatal with "Class not found".
268 // (create-widget applies the same guard; see wdesignkit_mcp_strip_php_namespace.)
269 $php_for_write = function_exists('wdesignkit_mcp_strip_php_namespace')
270 ? wdesignkit_mcp_strip_php_namespace((string) $input['php_code'])
271 : (string) $input['php_code'];
272 $maybe_write($file_base . '.php', $php_for_write);
273 }
274
275 if (isset($input['css_code']) && $input['css_code'] !== '') {
276 $maybe_write($file_base . '.css', $input['css_code']);
277 }
278
279 if (isset($input['js_code']) && $input['js_code'] !== '') {
280 $maybe_write($file_base . '.js', $input['js_code']);
281
282 // Gutenberg blocks may have a legacy index.js at the folder root used by some setups.
283 // Only sync it if the file exists — never create it; the registered script handle
284 // points to {file_name}.js, not index.js.
285 if (in_array($builder, ['gutenberg', 'gutenberg_core'], true)) {
286 $index_path = $widget_dir . '/index.js';
287 if (file_exists($index_path)) {
288 $maybe_write($index_path, $input['js_code']);
289 }
290 }
291 }
292
293 if (!empty($write_errors)) {
294 return [
295 'success' => false,
296 'message' => 'Failed to write: ' . implode(', ', $write_errors) . '. Check file permissions.',
297 'files_updated' => $files_updated,
298 ];
299 }
300
301 // --- Update JSON config metadata ---
302 // Initialize with a safe default so the variable is always defined when used below
303 $json_data = ['widget_data' => ['widgetdata' => []]];
304
305 $meta_changed = false;
306 if ($json_file && file_exists($json_file)) {
307 $raw_json = @file_get_contents($json_file);
308 $decoded = ($raw_json !== false) ? json_decode($raw_json, true) : null;
309
310 if ($decoded !== null && json_last_error() === JSON_ERROR_NONE) {
311 $json_data = $decoded;
312 }
313 // If JSON is corrupt, $json_data retains the safe default above — the write will
314 // overwrite the corrupt file with only the fields we're changing.
315
316 if (!empty($new_name)) {
317 $json_data['widget_data']['widgetdata']['name'] = $new_name;
318 $meta_changed = true;
319 }
320 if (isset($input['description']) && $input['description'] !== '') {
321 $json_data['widget_data']['widgetdata']['description'] = sanitize_text_field($input['description']);
322 $meta_changed = true;
323 }
324 if (isset($input['helper_link'])) {
325 $json_data['widget_data']['widgetdata']['helper_link'] = esc_url_raw((string) $input['helper_link']);
326 $meta_changed = true;
327 }
328 if (!empty($input['version'])) {
329 $json_data['widget_data']['widgetdata']['widget_version'] = sanitize_text_field($input['version']);
330 $meta_changed = true;
331 }
332
333 // --- Resync builder panel (section_data) + Editor_data when code changes ---
334 // The WDesignKit builder panel reads section_data / Editor_data, NOT the raw code
335 // files — so updating a widget's code otherwise leaves the panel showing the old
336 // controls and preview. Mirror create-widget: re-parse register_controls() from the
337 // new PHP into section_data, regenerate the canvas HTML from it, and keep the preview
338 // CSS/JS in step with the written files.
339 $code_touched = (isset($input['php_code']) && $input['php_code'] !== '')
340 || (isset($input['css_code']) && $input['css_code'] !== '')
341 || (isset($input['js_code']) && $input['js_code'] !== '');
342
343 if ($code_touched) {
344 if (!isset($json_data['Editor_data']) || !is_array($json_data['Editor_data'])) {
345 $json_data['Editor_data'] = ['html' => '', 'css' => '', 'js' => ''];
346 }
347
348 // section_data + canvas HTML — only when new PHP is supplied and it parses.
349 // A null parse (e.g. non-Elementor register_controls) leaves the existing
350 // section_data untouched rather than clobbering it with defaults.
351 if (isset($input['php_code']) && $input['php_code'] !== ''
352 && !isset($input['section_data'])
353 && function_exists('wdesignkit_mcp_parse_php_section_data')) {
354
355 $wd_name = (string) ($json_data['widget_data']['widgetdata']['name'] ?? $folder);
356 $wd_id = (string) ($json_data['widget_data']['widgetdata']['widget_id'] ?? '');
357 $file_name = basename($file_base);
358
359 $_slug = sanitize_title($wd_name);
360 $widget_css_class = 'wdkit-' . $_slug . (substr($_slug, -7) === '-widget' ? '' : '-widget');
361
362 $parsed = wdesignkit_mcp_parse_php_section_data((string) $input['php_code'], $widget_css_class);
363 if (is_array($parsed)) {
364 $json_data['section_data'] = $parsed;
365 $meta_changed = true;
366
367 if (function_exists('wdesignkit_mcp_generate_editor_html_from_section_data')) {
368 $json_data['Editor_data']['html'] = wdesignkit_mcp_generate_editor_html_from_section_data(
369 $wd_id, $widget_css_class, $file_name, $parsed
370 );
371 }
372 }
373 }
374
375 if (isset($input['css_code']) && $input['css_code'] !== '') {
376 $json_data['Editor_data']['css'] = (string) $input['css_code'];
377 $meta_changed = true;
378 }
379 if (isset($input['js_code']) && $input['js_code'] !== '') {
380 $json_data['Editor_data']['js'] = (string) $input['js_code'];
381 $meta_changed = true;
382 }
383 }
384
385 if ($meta_changed) {
386 $new_json_content = wp_json_encode($json_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
387 $maybe_write($json_file, $new_json_content);
388 }
389 }
390
391 // --- When renaming: update get_title() (Elementor) and get_label() (Bricks) in PHP ---
392 // Only attempt this when a name change was requested AND we haven't already replaced the
393 // entire PHP file via php_code (in that case the caller is responsible for the new title).
394 if (!empty($new_name) && !isset($input['php_code']) && file_exists($file_base . '.php')) {
395 $php_content = @file_get_contents($file_base . '.php');
396
397 if ($php_content !== false) {
398 // Escape new name for safe insertion into a PHP single-quoted string literal
399 $safe_new_name = str_replace(["\\", "'"], ["\\\\", "\\'"], $new_name);
400
401 // Pattern: match the body of get_title() or get_label() and replace the string
402 // argument of the esc_html__() call. The [^}]* is bounded by the closing brace
403 // of the method, so the match cannot bleed into sibling methods.
404 // Two separate passes: one for Elementor (get_title), one for Bricks (get_label).
405 $pattern = '/(\bfunction\s+get_%s\s*\(\s*\)\s*\{[^}]*\breturn\s+esc_html__\s*\(\s*\')[^\']*(\'[^)]*\)\s*;)/s';
406
407 $updated_php = preg_replace_callback(
408 sprintf($pattern, 'title'),
409 static function (array $m) use ($safe_new_name): string {
410 return $m[1] . $safe_new_name . $m[2];
411 },
412 $php_content,
413 1
414 );
415
416 // preg_replace_callback returns null only on a regex compile error; fall back safely
417 if ($updated_php === null) {
418 $updated_php = $php_content;
419 }
420
421 $updated_php = preg_replace_callback(
422 sprintf($pattern, 'label'),
423 static function (array $m) use ($safe_new_name): string {
424 return $m[1] . $safe_new_name . $m[2];
425 },
426 $updated_php,
427 1
428 );
429
430 if ($updated_php === null) {
431 $updated_php = $php_content;
432 }
433
434 if ($updated_php !== $php_content) {
435 $maybe_write($file_base . '.php', $updated_php);
436 }
437 }
438 }
439
440 // --- Propagate version to PHP enqueue/register version strings ---
441 // When version is updated, patch all wp_enqueue_script/style and wp_register_script/style
442 // calls in the PHP file so asset cache-busting version strings stay in sync with the JSON.
443 // Only runs when php_code is NOT provided (caller is responsible in that case).
444 if (!empty($input['version']) && !isset($input['php_code']) && file_exists($file_base . '.php')) {
445 $php_content = @file_get_contents($file_base . '.php');
446 if ($php_content !== false) {
447 $new_version = sanitize_text_field($input['version']);
448 // Match the version argument in wp_(enqueue|register)_(script|style)() calls.
449 // The version argument follows the dependencies array close (']', 'false', or 'null')
450 // and a comma+optional whitespace.
451 $updated_php = preg_replace_callback(
452 '/(wp_(?:enqueue|register)_(?:script|style)\s*\([^;]*?(?:\]|false|null)\s*,\s*)[\'"][\d.]+[\'"]/s',
453 static function (array $m) use ($new_version): string {
454 return $m[1] . "'" . $new_version . "'";
455 },
456 $php_content
457 );
458 if ($updated_php !== null && $updated_php !== $php_content) {
459 $maybe_write($file_base . '.php', $updated_php);
460 }
461 }
462 }
463
464 // --- Propagate description to PHP header comment ---
465 // Updates the "* Description:" line in the PHP file's doc-block header if present.
466 // Only runs when php_code is NOT provided.
467 if (isset($input['description']) && $input['description'] !== '' && !isset($input['php_code']) && file_exists($file_base . '.php')) {
468 $php_content = @file_get_contents($file_base . '.php');
469 if ($php_content !== false) {
470 // Sanitize: prevent comment break-out via */ sequence or newlines
471 $safe_desc = str_replace(['*/', "\n", "\r"], ['* /', ' ', ' '], sanitize_text_field($input['description']));
472 $updated_php = preg_replace(
473 '/(\* Description:)[^\n]*/',
474 '$1 ' . $safe_desc,
475 $php_content,
476 1
477 );
478 if ($updated_php !== null && $updated_php !== $php_content) {
479 $maybe_write($file_base . '.php', $updated_php);
480 }
481 }
482 }
483
484 // Bump the directory mtime so list-widgets reflects the change immediately
485 if (!empty($files_updated)) {
486 @touch($widget_dir);
487 }
488
489 // Collect current widget identity for the response
490 $widget_name = $json_data['widget_data']['widgetdata']['name'] ?? $folder;
491 $widget_id = $json_data['widget_data']['widgetdata']['widget_id'] ?? '';
492
493 if (empty($files_updated)) {
494 return [
495 'success' => true,
496 'message' => 'No changes detected. The provided content is identical to the existing files.',
497 'files_updated' => [],
498 'widget_name' => $widget_name,
499 'widget_id' => $widget_id,
500 ];
501 }
502
503 return [
504 'success' => true,
505 'message' => 'Widget updated successfully. ' . count($files_updated) . ' file(s) modified.',
506 'files_updated' => $files_updated,
507 'widget_name' => $widget_name,
508 'widget_id' => $widget_id,
509 ];
510 }
511