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
← All changes | includes/abilities/widgets/wdesignkit-duplicate-widget.php +116 -23 2.5.5 → 2.7.1 View file →
@@ -9,12 +9,12 @@
9 9 exit();
10 10 }
11 11
12 12 wp_register_ability('wdesignkit/duplicate-widget', [
13 - 'label' => __('Duplicate WDesignKit Widget', 'sprout-mcp'),
13 + 'label' => __('Duplicate WDesignKit Widget', 'wdesignkit'),
14 14 'description' => __(
15 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 - 'sprout-mcp',
16 + 'wdesignkit',
17 17 ),
18 18 'category' => 'wdesignkit',
19 19 'input_schema' => [
20 20 'type' => 'object',
@@ -47,10 +47,11 @@
47 47 'new_widget_name'=> ['type' => 'string'],
48 48 ],
49 49 ],
50 50 'execute_callback' => 'wdesignkit_mcp_duplicate_widget',
51 - 'permission_callback' => 'sprout_mcp_permission_callback',
51 + 'permission_callback' => 'wdesignkit_mcp_permission_callback',
52 52 'meta' => [
53 + 'public' => true,
53 54 'show_in_rest' => true,
54 55 'mcp' => ['public' => true],
55 56 'annotations' => [
56 57 'instructions' => implode("\n", [
@@ -70,8 +71,20 @@
70 71 if (!defined('WDKIT_BUILDER_PATH')) {
71 72 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
72 73 }
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 +
74 87 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
75 88 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
76 89
77 90 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
@@ -96,16 +109,21 @@
96 109 $json_path = null;
97 110 $orig_json = null;
98 111 $orig_name = $folder;
99 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 = '';
100 117
101 118 foreach ($src_files as $f) {
102 119 if (pathinfo($f, PATHINFO_EXTENSION) !== 'json') {
103 120 continue;
104 121 }
105 - $json_path = $src_dir . '/' . $f;
106 - $raw = @file_get_contents($json_path);
107 - $orig_json = ($raw !== false) ? json_decode($raw, true) : null;
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;
108 126 if (is_array($orig_json)) {
109 127 $orig_name = $orig_json['widget_data']['widgetdata']['name'] ?? $folder;
110 128 $orig_widget_id = $orig_json['widget_data']['widgetdata']['widget_id'] ?? '';
111 129 }
@@ -122,13 +140,21 @@
122 140 }
123 141
124 142 // Generate a 6-char hex widget_id — consistent with the standard WDesignKit format (e.g. "a1b2c3").
125 143 // wp_generate_uuid4() produces a 36-char string that makes folder names excessively long.
126 - $new_widget_id = substr(bin2hex(random_bytes(3)), 0, 6);
127 - $new_folder = str_replace(' ', '-', $new_name) . '_' . $new_widget_id;
128 - $new_file_base = str_replace(' ', '_', $new_name) . '_' . $new_widget_id;
129 - $dst_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $new_folder;
144 + $new_widget_id = substr(bin2hex(random_bytes(3)), 0, 6);
130 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 +
131 157 if (is_dir($dst_dir)) {
132 158 return ['success' => false, 'message' => "Target folder already exists: {$builder}/{$new_folder}"];
133 159 }
134 160
@@ -135,35 +161,102 @@
135 161 if (!wp_mkdir_p($dst_dir)) {
136 162 return ['success' => false, 'message' => 'Could not create destination folder.'];
137 163 }
138 164
139 - // Copy each file, renaming to match new folder/name
140 - $orig_file_base = str_replace(' ', '_', $orig_name) . '_' . $orig_widget_id;
165 + if ($orig_file_base === '') {
166 + $orig_file_base = $folder; // defensive; a missing JSON already returned above.
167 + }
141 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 +
142 219 foreach ($src_files as $f) {
143 220 $src_file = $src_dir . '/' . $f;
144 221 $ext = pathinfo($f, PATHINFO_EXTENSION);
145 222
146 - // Rename file using new_file_base
147 - $new_filename = str_replace($orig_file_base, $new_file_base, $f);
148 - // Fallback: if rename didn't match, just copy with original name
149 - if ($new_filename === $f && $orig_file_base !== '') {
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) {
150 226 $new_filename = $new_file_base . '.' . $ext;
151 227 }
152 228 $dst_file = $dst_dir . '/' . $new_filename;
153 229
154 230 if ($ext === 'json') {
155 - // Patch JSON: update name + widget_id
231 + // Patch metadata, then rewrite identifiers so section_data / Editor_data / asset
232 + // paths stay in sync with the rewritten code files.
156 233 $new_json = $orig_json;
157 - $new_json['widget_data']['widgetdata']['name'] = $new_name;
158 - $new_json['widget_data']['widgetdata']['widget_id'] = $new_widget_id;
159 - // Clear cloud-specific fields so it starts as local-only
234 + $new_json['widget_data']['widgetdata']['name'] = $new_name;
235 + $new_json['widget_data']['widgetdata']['widget_id'] = $new_widget_id;
160 236 $new_json['widget_data']['widgetdata']['r_id'] = 0;
161 - $new_json['widget_data']['widgetdata']['allow_push'] = false;
162 - file_put_contents($dst_file, wp_json_encode($new_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
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 + }
163 252 } else {
164 - @copy($src_file, $dst_file);
253 + @copy($src_file, $dst_file); // binary assets (thumbnail image, etc.)
165 254 }
255 + }
256 +
257 + if (function_exists('wdesignkit_invalidate_widget_registry')) {
258 + wdesignkit_invalidate_widget_registry($builder);
166 259 }
167 260
168 261 return [
169 262 'success' => true,