PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
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-duplicate-widget.php

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

269 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Duplicate a local WDesignKit widget to a new copy with a fresh widget_id.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/duplicate-widget', [
13 'label' => __('Duplicate WDesignKit Widget', 'wdesignkit'),
14 'description' => __(
15 'Creates a copy of a local widget under a new name. All files (JSON, PHP, CSS, JS, image) are duplicated into a new folder. The JSON config is updated with a freshly generated widget_id and the new name. The duplicate is immediately visible in wdesignkit/list-widgets. Cloud records are NOT duplicated — the copy starts as a local-only widget.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'builder' => [
23 'type' => 'string',
24 'description' => 'Builder type of the source widget.',
25 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'],
26 ],
27 'folder' => [
28 'type' => 'string',
29 'description' => 'Source widget folder name (from wdesignkit/list-widgets).',
30 ],
31 'new_name' => [
32 'type' => 'string',
33 'description' => 'Display name for the duplicate. Defaults to "<original name> - Copy" when omitted.',
34 'maxLength' => 64,
35 ],
36 ],
37 'required' => ['builder', 'folder'],
38 'additionalProperties' => false,
39 ],
40 'output_schema' => [
41 'type' => 'object',
42 'properties' => [
43 'success' => ['type' => 'boolean'],
44 'message' => ['type' => 'string'],
45 'new_folder' => ['type' => 'string'],
46 'new_widget_id' => ['type' => 'string'],
47 'new_widget_name'=> ['type' => 'string'],
48 ],
49 ],
50 'execute_callback' => 'wdesignkit_mcp_duplicate_widget',
51 'permission_callback' => 'wdesignkit_mcp_permission_callback',
52 'meta' => [
53 'public' => true,
54 'show_in_rest' => true,
55 'mcp' => ['public' => true],
56 'annotations' => [
57 'instructions' => implode("\n", [
58 'Clones a local widget. Cloud records are NOT copied — the duplicate is local-only until pushed.',
59 'Does NOT require cloud login.',
60 'The duplicate gets a new widget_id (6-char hex, e.g. "a1b2c3") so it never conflicts with the original.',
61 'Use new_name to control the display name; the folder name is derived from it.',
62 ]),
63 'readonly' => false,
64 'destructive' => false,
65 'idempotent' => false,
66 ],
67 ],
68 ]);
69
70 function wdesignkit_mcp_duplicate_widget(array $input): array {
71 if (!defined('WDKIT_BUILDER_PATH')) {
72 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
73 }
74
75 // Site-level opt-out for generated widget PHP (ClickUp 86d41zavc). This ability introduces no
76 // new code — it clones a widget already on disk and running — but it does write a new
77 // executable .php file, so a site that has turned widget PHP writes off should not get one.
78 // Refusing the whole call rather than copying everything except the .php: a widget folder with
79 // no PHP is not a working widget, so a partial duplicate would be worse than none.
80 if (function_exists('wdesignkit_widget_php_write_allowed') && !wdesignkit_widget_php_write_allowed()) {
81 return [
82 'success' => false,
83 'message' => 'Widget duplication is disabled on this site: generated widget PHP writes have been turned off via the wdesignkit_allow_widget_php_write filter. Duplicating would have to write a new .php file.',
84 ];
85 }
86
87 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
88 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
89
90 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
91 if (!in_array($builder, $allowed_builders, true)) {
92 return ['success' => false, 'message' => 'Invalid builder type.'];
93 }
94
95 $src_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $folder;
96
97 if (!is_dir($src_dir)) {
98 return ['success' => false, 'message' => "Widget folder not found: {$builder}/{$folder}"];
99 }
100
101 $real_src = realpath($src_dir);
102 $real_base = realpath(WDKIT_BUILDER_PATH);
103 if (!$real_src || !$real_base || strpos($real_src, $real_base . DIRECTORY_SEPARATOR) !== 0) {
104 return ['success' => false, 'message' => 'Invalid widget path.'];
105 }
106
107 // Read source JSON to get existing metadata
108 $src_files = array_diff(@scandir($src_dir) ?: [], ['.', '..']);
109 $json_path = null;
110 $orig_json = null;
111 $orig_name = $folder;
112 $orig_widget_id = '';
113 // Real original file base = the actual JSON filename (every file in the folder —
114 // php/js/css/image — shares it). Read it from disk rather than rebuilding it from the
115 // name, so the identifier rewrites match exactly what is inside the copied files.
116 $orig_file_base = '';
117
118 foreach ($src_files as $f) {
119 if (pathinfo($f, PATHINFO_EXTENSION) !== 'json') {
120 continue;
121 }
122 $json_path = $src_dir . '/' . $f;
123 $orig_file_base = pathinfo($f, PATHINFO_FILENAME);
124 $raw = @file_get_contents($json_path);
125 $orig_json = ($raw !== false) ? json_decode($raw, true) : null;
126 if (is_array($orig_json)) {
127 $orig_name = $orig_json['widget_data']['widgetdata']['name'] ?? $folder;
128 $orig_widget_id = $orig_json['widget_data']['widgetdata']['widget_id'] ?? '';
129 }
130 break;
131 }
132
133 if ($orig_json === null) {
134 return ['success' => false, 'message' => "Could not read JSON config from {$builder}/{$folder}"];
135 }
136
137 $new_name = sanitize_text_field((string) ($input['new_name'] ?? ($orig_name . ' - Copy')));
138 if ($new_name === '') {
139 $new_name = $orig_name . ' - Copy';
140 }
141
142 // Generate a 6-char hex widget_id — consistent with the standard WDesignKit format (e.g. "a1b2c3").
143 // wp_generate_uuid4() produces a 36-char string that makes folder names excessively long.
144 $new_widget_id = substr(bin2hex(random_bytes(3)), 0, 6);
145
146 // Derive the new folder / file base with the SAME helpers every other writer uses, so a
147 // duplicate never lands in a differently-cased twin of an existing folder (ClickUp
148 // 86d3yk4yx). The file base still uses underscores, which keeps the Elementor loader —
149 // it instantiates 'Wdkit_' . str_replace('-','_', filename) — resolving the class.
150 if (sanitize_file_name($new_name) === '') {
151 $new_name = 'Widget';
152 }
153 $new_folder = wdesignkit_widget_folder_name($new_name, $new_widget_id);
154 $new_file_base = wdesignkit_widget_file_name($new_name, $new_widget_id);
155 $dst_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $new_folder;
156
157 if (is_dir($dst_dir)) {
158 return ['success' => false, 'message' => "Target folder already exists: {$builder}/{$new_folder}"];
159 }
160
161 if (!wp_mkdir_p($dst_dir)) {
162 return ['success' => false, 'message' => 'Could not create destination folder.'];
163 }
164
165 if ($orig_file_base === '') {
166 $orig_file_base = $folder; // defensive; a missing JSON already returned above.
167 }
168
169 // --- Independence rewrites ----------------------------------------------------------
170 // Copying files byte-for-byte kept the ORIGINAL class name (Elementor: Wdkit_{file};
171 // Bricks: {Pascal}_Bricks), block/element names, asset paths and hashes — so the instant
172 // the duplicate co-existed with the original, PHP fataled with "Cannot declare class ...
173 // already in use" (or a block/element registered twice) and the whole site 500'd. Rewrite
174 // every self-referential identifier so the duplicate is a fully independent widget.
175 $new_class = 'Wdkit_' . str_replace('-', '_', $new_file_base);
176 $new_class_bricks = $new_class . '_Bricks';
177 $orig_slug = sanitize_title($orig_name);
178 $new_slug = sanitize_title($new_name);
179
180 // Builder-aware PHP class rename (first declaration only). Elementor must match the loader's
181 // derived name exactly; Bricks just needs a unique valid class (its loader resolves the
182 // declared class dynamically). Gutenberg/gutenberg_core use functions, not a class, so the
183 // regex simply finds nothing there.
184 $rewrite_php_class = static function (string $php) use ($builder, $new_class, $new_class_bricks): string {
185 if ($builder === 'elementor') {
186 $out = preg_replace('/\bclass\s+\w+\s+extends\b/', 'class ' . $new_class . ' extends', $php, 1);
187 return is_string($out) ? $out : $php;
188 }
189 if ($builder === 'bricks') {
190 $out = preg_replace('/\bclass\s+\w+\s+extends\b/', 'class ' . $new_class_bricks . ' extends', $php, 1);
191 return is_string($out) ? $out : $php;
192 }
193 return $php;
194 };
195
196 // Ordered string rewrites for paths / block names / element $name / asset handles / hashes.
197 // Composite tokens (folder + file base both END with the OLD widget_id) run before the bare
198 // widget_id so its suffix isn't corrupted first.
199 $rewrites = [];
200 if ($folder !== $new_folder) { $rewrites[] = [$folder, $new_folder]; }
201 if ($orig_file_base !== $new_file_base) { $rewrites[] = [$orig_file_base, $new_file_base]; }
202 if ($orig_slug !== '' && $orig_slug !== $new_slug) {
203 $rewrites[] = ['wdkit/' . $orig_slug, 'wdkit/' . $new_slug]; // Gutenberg block name
204 $rewrites[] = ['wdkit-' . $orig_slug, 'wdkit-' . $new_slug]; // Bricks $name + CSS class
205 }
206 if ($orig_widget_id !== '' && $orig_widget_id !== $new_widget_id) {
207 $rewrites[] = [$orig_widget_id, $new_widget_id]; // get_name 'wb-', handles, data-wdkitunique
208 }
209
210 $apply_rewrites = static function (string $s) use ($rewrites): string {
211 foreach ($rewrites as $p) {
212 if ($p[0] !== '') {
213 $s = str_replace($p[0], $p[1], $s);
214 }
215 }
216 return $s;
217 };
218
219 foreach ($src_files as $f) {
220 $src_file = $src_dir . '/' . $f;
221 $ext = pathinfo($f, PATHINFO_EXTENSION);
222
223 // Rename the file to the new base.
224 $new_filename = ($orig_file_base !== '') ? str_replace($orig_file_base, $new_file_base, $f) : $f;
225 if ($new_filename === $f) {
226 $new_filename = $new_file_base . '.' . $ext;
227 }
228 $dst_file = $dst_dir . '/' . $new_filename;
229
230 if ($ext === 'json') {
231 // Patch metadata, then rewrite identifiers so section_data / Editor_data / asset
232 // paths stay in sync with the rewritten code files.
233 $new_json = $orig_json;
234 $new_json['widget_data']['widgetdata']['name'] = $new_name;
235 $new_json['widget_data']['widgetdata']['widget_id'] = $new_widget_id;
236 $new_json['widget_data']['widgetdata']['r_id'] = 0;
237 $new_json['widget_data']['widgetdata']['allow_push'] = false;
238 $json_out = (string) wp_json_encode($new_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
239 file_put_contents($dst_file, $apply_rewrites($json_out));
240 } elseif (in_array($ext, ['php', 'js', 'css'], true)) {
241 $content = @file_get_contents($src_file);
242 if ($content === false) {
243 @copy($src_file, $dst_file);
244 } else {
245 if ($ext === 'php') {
246 $content = $rewrite_php_class($content);
247 }
248 if (file_put_contents($dst_file, $apply_rewrites($content)) === false) {
249 @copy($src_file, $dst_file);
250 }
251 }
252 } else {
253 @copy($src_file, $dst_file); // binary assets (thumbnail image, etc.)
254 }
255 }
256
257 if (function_exists('wdesignkit_invalidate_widget_registry')) {
258 wdesignkit_invalidate_widget_registry($builder);
259 }
260
261 return [
262 'success' => true,
263 'message' => "Widget '{$orig_name}' duplicated as '{$new_name}'.",
264 'new_folder' => $new_folder,
265 'new_widget_id' => $new_widget_id,
266 'new_widget_name' => $new_name,
267 ];
268 }
269