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

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

281 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Import a WDesignKit widget from its JSON config into the local widget library.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/import-widget', [
13 'label' => __('Import WDesignKit Widget', 'wdesignkit'),
14 'description' => __(
15 'Imports a widget into the local WDesignKit library from its JSON config object. Provide the full widget_data JSON (same structure as the .json file inside a .wdk ZIP export). The ability creates the correct builder folder, writes the JSON, and optionally downloads the widget thumbnail. Rejects imports when a widget with the same widget_id already exists.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'widget_json' => [
23 'type' => 'object',
24 'description' => 'The parsed widget config object — the top-level structure is {"widget_data":{"widgetdata":{…}}}. This is the same JSON that lives inside the .wdk ZIP export.',
25 ],
26 'image_url' => [
27 'type' => 'string',
28 'description' => 'Optional URL of the widget thumbnail image. Downloaded and stored alongside the JSON. Used only when image_base64 is not provided.',
29 ],
30 'image_base64' => [
31 'type' => 'string',
32 'description' => 'Optional widget thumbnail supplied inline as base64 (raw base64, or a data:image/...;base64,... URI). Use this to import the thumbnail bundled INSIDE a .wdk ZIP export — mirrors the manual Import UI, no separately-hosted URL needed. Takes precedence over image_url.',
33 ],
34 'image_ext' => [
35 'type' => 'string',
36 'description' => 'Optional image extension (png, jpg, jpeg, webp) for image_base64 when it is raw base64 without a data: URI. Defaults to the widget JSON img_ext, or "png".',
37 ],
38 'overwrite' => [
39 'type' => 'boolean',
40 'description' => 'When true, overwrites an existing widget that shares the same widget_id instead of rejecting the import.',
41 ],
42 ],
43 'required' => ['widget_json'],
44 'additionalProperties' => false,
45 ],
46 'output_schema' => [
47 'type' => 'object',
48 'properties' => [
49 'success' => ['type' => 'boolean'],
50 'message' => ['type' => 'string'],
51 'folder' => ['type' => 'string'],
52 'builder' => ['type' => 'string'],
53 'widget_id' => ['type' => 'string'],
54 'widget_name' => ['type' => 'string'],
55 ],
56 ],
57 'execute_callback' => 'wdesignkit_mcp_import_widget',
58 'permission_callback' => 'wdesignkit_mcp_permission_callback',
59 'meta' => [
60 'public' => true,
61 'show_in_rest' => true,
62 'mcp' => ['public' => true],
63 'annotations' => [
64 'instructions' => implode("\n", [
65 'Imports a widget from its JSON config into the local library.',
66 'Does NOT require cloud login — this is a local filesystem operation.',
67 'widget_json must be the full widget_data structure ({"widget_data":{"widgetdata":{…}}}) — get it from wdesignkit/get-widget or from a .wdk ZIP export.',
68 'To import a .wdk ZIP\'s bundled thumbnail, pass its bytes as image_base64 (no external URL needed); use image_url only for a remotely-hosted image.',
69 'Fails if a widget with the same widget_id already exists unless overwrite: true.',
70 'After a successful import the widget appears in wdesignkit/list-widgets.',
71 ]),
72 'readonly' => false,
73 'destructive' => false,
74 'idempotent' => false,
75 ],
76 ],
77 ]);
78
79 function wdesignkit_mcp_import_widget(array $input): array {
80 // Timeout guard: local filesystem write + optional 30s thumbnail download.
81 set_time_limit(60);
82
83 if (!defined('WDKIT_BUILDER_PATH')) {
84 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
85 }
86
87 $widget_json = $input['widget_json'] ?? null;
88 if (!is_array($widget_json)) {
89 return ['success' => false, 'message' => 'widget_json must be a JSON object with a widget_data key.'];
90 }
91
92 $widgetdata = $widget_json['widget_data']['widgetdata'] ?? null;
93 if (!is_array($widgetdata)) {
94 return ['success' => false, 'message' => 'widget_json.widget_data.widgetdata is missing or invalid.'];
95 }
96
97 $widget_name = sanitize_text_field((string) ($widgetdata['name'] ?? ''));
98 $widget_id = sanitize_text_field((string) ($widgetdata['widget_id'] ?? ''));
99 $builder = sanitize_key((string) ($widgetdata['type'] ?? ''));
100
101 if ($widget_name === '' || $widget_id === '' || $builder === '') {
102 return ['success' => false, 'message' => 'widget_json must include widget_data.widgetdata.name, widget_id, and type.'];
103 }
104
105 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
106 if (!in_array($builder, $allowed_builders, true)) {
107 return ['success' => false, 'message' => "Unsupported builder type: {$builder}."];
108 }
109
110 $overwrite = !empty($input['overwrite']);
111 // Canonical helpers: they replace spaces BEFORE sanitize_file_name(). Sanitising first
112 // (as this did) already collapsed spaces to hyphens, so the underscore pass was a no-op
113 // and a multi-word title produced "My-Widget_id.json" while the builder's own save path
114 // writes "My_Widget_id.php". The registry resolves the JSON by swapping .php for .json,
115 // so that pair never matched and the widget was skipped for good (ClickUp 86d41cck5).
116 $folder_name = wdesignkit_widget_folder_name($widget_name, $widget_id);
117 $file_name = wdesignkit_widget_file_name($widget_name, $widget_id);
118 $builder_dir = WDKIT_BUILDER_PATH . '/' . $builder;
119 $widget_dir = $builder_dir . '/' . $folder_name;
120
121 // Duplicate check / overwrite: scan for any existing folder sharing the same widget_id.
122 // Primary strategy: read each folder's JSON and compare widget_data.widgetdata.widget_id.
123 // Fallback strategy: folder names follow the pattern "<name>_<widget_id>", so any folder
124 // whose name ends with "_{$widget_id}" is also treated as a match even if the JSON is
125 // unreadable or has an unexpected structure.
126 $existing_folders = @scandir($builder_dir) ?: [];
127 foreach (array_diff($existing_folders, ['.', '..']) as $ef) {
128 $jf = $builder_dir . '/' . $ef;
129 if (!is_dir($jf)) {
130 continue;
131 }
132
133 $found = false;
134
135 // Primary: inspect the JSON content.
136 $sub = @scandir($jf) ?: [];
137 foreach ($sub as $sf) {
138 if (pathinfo($sf, PATHINFO_EXTENSION) !== 'json') {
139 continue;
140 }
141 $raw = @file_get_contents($jf . '/' . $sf);
142 $jd = ($raw !== false) ? json_decode($raw, true) : null;
143 if (is_array($jd) && ($jd['widget_data']['widgetdata']['widget_id'] ?? '') === $widget_id) {
144 $found = true;
145 break;
146 }
147 }
148
149 // Fallback: match by folder-name suffix ("_<widget_id>").
150 if (!$found && str_ends_with($ef, '_' . $widget_id)) {
151 $found = true;
152 }
153
154 if ($found) {
155 if (!$overwrite) {
156 return [
157 'success' => false,
158 'message' => "A widget with widget_id '{$widget_id}' already exists in folder '{$ef}'. Pass overwrite: true to force.",
159 ];
160 }
161 // overwrite=true: delete the existing folder so only one copy exists after import.
162 wdesignkit_mcp_import_widget_rmdir($jf);
163 break;
164 }
165 }
166
167 if (!wp_mkdir_p($widget_dir)) {
168 return ['success' => false, 'message' => "Could not create widget folder: {$builder}/{$folder_name}"];
169 }
170
171 // Realpath validation — ensure we're still inside WDKIT_BUILDER_PATH.
172 $real_widget = realpath($widget_dir);
173 $real_base = realpath(WDKIT_BUILDER_PATH);
174 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
175 return ['success' => false, 'message' => 'Invalid widget path.'];
176 }
177
178 $json_path = $widget_dir . '/' . $file_name . '.json';
179 $written = @file_put_contents($json_path, wp_json_encode($widget_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
180
181 if ($written === false) {
182 return ['success' => false, 'message' => 'Could not write widget JSON file.'];
183 }
184
185 // Thumbnail. Prefer an inline base64 image — this is how a .wdk ZIP's OWN bundled
186 // thumbnail is imported (mirrors the manual Import UI's ZipArchive extraction). Fall
187 // back to downloading a remote image_url. Whichever is used, the JSON's w_image / img_ext
188 // are pointed at the saved local file so the library shows it and push-widget finds it.
189 $image_saved = false;
190 $image_source = '';
191 $image_base64 = (string) ($input['image_base64'] ?? '');
192 $image_url = sanitize_url((string) ($input['image_url'] ?? ''));
193
194 $img_bytes = '';
195 $img_ext = '';
196
197 if ($image_base64 !== '') {
198 $b64 = $image_base64;
199 if (preg_match('#^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$#s', $b64, $m)) {
200 $img_ext = $m[1];
201 $b64 = $m[2];
202 }
203 $b64 = preg_replace('/\s+/', '', (string) $b64);
204 $decoded = ($b64 !== '') ? base64_decode($b64, true) : false;
205 if ($decoded !== false && $decoded !== '') {
206 $img_bytes = $decoded;
207 if ($img_ext === '') {
208 $img_ext = (string) ($input['image_ext'] ?? ($widgetdata['img_ext'] ?? 'png'));
209 }
210 $image_source = 'bundled';
211 }
212 } elseif ($image_url !== '') {
213 // SSRF guard (CWE-918): validate the resolved host before fetching a caller-supplied URL.
214 $img_resp = wdesignkit_safe_remote_get($image_url, ['timeout' => 30]);
215 if (!is_wp_error($img_resp)) {
216 $img_bytes = (string) wp_remote_retrieve_body($img_resp);
217 $img_ext = pathinfo(parse_url($image_url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION) ?: 'png';
218 $image_source = 'downloaded';
219 }
220 }
221
222 if ($img_bytes !== '') {
223 // Both branches above take the extension from caller-supplied data — a data-URI subtype or
224 // the remote URL. The old preg_replace() only stripped punctuation, so "php" survived and
225 // this write could drop executable PHP into the builder directory (CWE-434,
226 // ClickUp 86d41cczd). Verify against the decoded bytes instead; '' means not an image.
227 // The synthetic "image.<ext>" filename lets the helper read either branch's extension.
228 $img_ext = wdesignkit_safe_image_extension('image.' . strtolower((string) $img_ext), $img_bytes);
229 }
230
231 if ($img_bytes !== '' && $img_ext !== '') {
232 $img_path = $widget_dir . '/' . $file_name . '.' . $img_ext;
233 if (@file_put_contents($img_path, $img_bytes) !== false) {
234 $image_saved = true;
235 // Point the JSON at the local thumbnail so it renders in the library and push finds it.
236 if (defined('WDKIT_SERVER_PATH')) {
237 $widget_json['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$builder}/{$folder_name}/{$file_name}.{$img_ext}";
238 }
239 $widget_json['widget_data']['widgetdata']['img_ext'] = $img_ext;
240 @file_put_contents($json_path, wp_json_encode($widget_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
241 }
242 }
243
244 $thumb_note = $image_saved
245 ? ($image_source === 'bundled' ? ' Bundled thumbnail imported.' : ' Thumbnail downloaded.')
246 : '';
247
248 if (function_exists('wdesignkit_invalidate_widget_registry')) {
249 wdesignkit_invalidate_widget_registry($builder);
250 }
251
252 return [
253 'success' => true,
254 'message' => "Widget '{$widget_name}' imported successfully." . $thumb_note,
255 'folder' => $folder_name,
256 'builder' => $builder,
257 'widget_id' => $widget_id,
258 'widget_name' => $widget_name,
259 ];
260 }
261
262 /**
263 * Recursively delete a directory and all its contents.
264 * Used by wdesignkit_mcp_import_widget to remove an existing widget folder on overwrite.
265 */
266 function wdesignkit_mcp_import_widget_rmdir(string $dir): void {
267 if (!is_dir($dir)) {
268 return;
269 }
270 $items = array_diff((array) scandir($dir), ['.', '..']);
271 foreach ($items as $item) {
272 $path = $dir . '/' . $item;
273 if (is_dir($path)) {
274 wdesignkit_mcp_import_widget_rmdir($path);
275 } else {
276 @unlink($path);
277 }
278 }
279 @rmdir($dir);
280 }
281