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-push-widget.php

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

295 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Push a local WDesignKit widget to the cloud marketplace.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/push-widget', [
13 'label' => __('Push WDesignKit Widget to Cloud', 'wdesignkit'),
14 'description' => __(
15 'Uploads a local widget to the WDesignKit cloud marketplace. Reads the widget files from disk, packages them, and posts to the save_widget endpoint. Requires cloud login. After a successful push the cloud returns a marketplace record ID (r_id) which is written back to the local JSON so future version checks work correctly.',
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 widget.',
25 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'],
26 ],
27 'folder' => [
28 'type' => 'string',
29 'description' => 'Widget folder name (from wdesignkit/list-widgets).',
30 ],
31 'type' => [
32 'type' => 'string',
33 'description' => 'Push type: "new" for first-time push, "update" to push an update to an existing record.',
34 'enum' => ['new', 'update'],
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 'r_id' => ['type' => 'integer', 'description' => 'Cloud marketplace record ID on success (0 if the cloud did not return one). Use this — not a value dug out of "response" — with wdesignkit/download-widget or wdesignkit/manage-workspace-widget to reference this widget afterwards.'],
46 'response' => ['type' => 'object'],
47 ],
48 ],
49 'execute_callback' => 'wdesignkit_mcp_push_widget',
50 'permission_callback' => 'wdesignkit_mcp_permission_callback',
51 'meta' => [
52 'public' => true,
53 'show_in_rest' => true,
54 'mcp' => ['public' => true],
55 'annotations' => [
56 'instructions' => implode("\n", [
57 'Pushes a local widget to the WDesignKit cloud marketplace. Requires login.',
58 'type defaults to "new". If the widget already has an r_id in its JSON, use "update".',
59 'After a successful push the local JSON is updated with the returned r_id and cloud image URL.',
60 'The response\'s top-level "r_id" field is the value to use afterwards — with wdesignkit/download-widget,',
61 'wdesignkit/manage-workspace-widget, or wdesignkit/get-my-cloud-widgets to find it again later.',
62 ]),
63 'readonly' => false,
64 'destructive' => false,
65 'idempotent' => false,
66 ],
67 ],
68 ]);
69
70 function wdesignkit_mcp_push_widget(array $input): array {
71 // Timeout guard: cloud push uses a 60s HTTP timeout; set PHP limit beyond that.
72 set_time_limit(90);
73
74 if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_API_URL')) {
75 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
76 }
77
78 $auth = wdesignkit_mcp_template_get_auth();
79 if (empty($auth['logged_in'])) {
80 return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in.'];
81 }
82
83 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
84 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
85 $type = sanitize_text_field((string) ($input['type'] ?? 'new'));
86
87 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
88 if (!in_array($builder, $allowed_builders, true)) {
89 return ['success' => false, 'message' => 'Invalid builder type.'];
90 }
91
92 $widget_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $folder;
93
94 if (!is_dir($widget_dir)) {
95 return ['success' => false, 'message' => "Widget folder not found: {$builder}/{$folder}"];
96 }
97
98 $real_widget = realpath($widget_dir);
99 $real_base = realpath(WDKIT_BUILDER_PATH);
100 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
101 return ['success' => false, 'message' => 'Invalid widget path.'];
102 }
103
104 $files = array_diff(@scandir($widget_dir) ?: [], ['.', '..']);
105 $json_path = null;
106 $json_data = null;
107 $img_path = null;
108 $img_ext = null;
109
110 foreach ($files as $f) {
111 $ext = pathinfo($f, PATHINFO_EXTENSION);
112 if ($ext === 'json' && $json_path === null) {
113 $json_path = $widget_dir . '/' . $f;
114 $raw = @file_get_contents($json_path);
115 $json_data = ($raw !== false) ? json_decode($raw, true) : null;
116 } elseif (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true) && $img_path === null) {
117 $img_path = $widget_dir . '/' . $f;
118 $img_ext = $ext;
119 }
120 }
121
122 if (!is_array($json_data)) {
123 return ['success' => false, 'message' => "Could not read JSON config from {$builder}/{$folder}"];
124 }
125
126 $widgetdata = $json_data['widget_data']['widgetdata'] ?? [];
127 $title = sanitize_text_field((string) ($widgetdata['name'] ?? ''));
128 $widget_id = sanitize_text_field((string) ($widgetdata['widget_id'] ?? ''));
129 $r_id = (int) ($widgetdata['r_id'] ?? 0);
130 $w_version = (string) ($widgetdata['widget_version'] ?? '1.0.0');
131 $version_detail = $widgetdata['version_detail'] ?? [];
132 if (!is_array($version_detail)) {
133 $version_detail = [];
134 }
135
136 if ($title === '' || $widget_id === '') {
137 return ['success' => false, 'message' => 'Widget JSON is missing name or widget_id.'];
138 }
139
140 // Pre-flight: version must be a non-empty semver string (x.y.z).
141 // The cloud SetSaveWidget() endpoint rejects empty or non-semver versions with
142 // "Not allowed version number". Catch this locally before wasting a round-trip.
143 if ($w_version === '' || !preg_match('/^\d+\.\d+\.\d+$/', $w_version)) {
144 return [
145 'success' => false,
146 'message' => "Widget '{$title}' has an invalid widget_version '{$w_version}'. Set a valid semver version (e.g. '1.0.0') using wdesignkit/update-widget before pushing.",
147 ];
148 }
149
150 // Pre-flight: re-push version check.
151 // When r_id > 0 the widget is already in the marketplace. The cloud rejects
152 // pushes where the incoming w_version matches the already-published version.
153 // The UI enforces a "new version must be higher" rule — replicate it here so
154 // the caller gets a clear message instead of the opaque "Not allowed version number".
155 // We cannot know the exact cloud version without a network call, but we can
156 // detect the case where version_detail has only one entry (never bumped) and
157 // r_id > 0, which is the most common trigger of this error.
158 if ($r_id > 0 && count($version_detail) <= 1 && $w_version === '1.0.0') {
159 return [
160 'success' => false,
161 'message' => "Widget '{$title}' (r_id: {$r_id}) appears to already be published at version '1.0.0'. Bump widget_version (e.g. to '1.0.1') and update version_detail using wdesignkit/update-widget before re-pushing.",
162 'hint' => 'The cloud rejects re-pushes with the same version number. Increment widget_version in the JSON config first.',
163 ];
164 }
165
166 // Thumbnail is OPTIONAL, matching the manual "Push to Cloud" UI. SetSaveWidget
167 // (wdk-server WdkitPluginController) reads w_image with a '' default and only stores
168 // a file inside if( !empty($w_image) ) — it never validates or rejects an imageless
169 // push. Blocking here made this ability stricter than the product it wraps. (bug: 86d3rc7q4)
170 $w_image_body = '';
171 $w_imgext = '';
172
173 if ($img_path && file_exists($img_path)) {
174 $w_image_body = @file_get_contents($img_path);
175 $w_imgext = $img_ext;
176
177 if (false === $w_image_body) {
178 $w_image_body = '';
179 $w_imgext = '';
180 }
181 }
182
183 $array_data = [
184 'token' => $auth['token'],
185 // The cloud save_widget endpoint only understands 'add' — it determines
186 // new vs update by checking whether the widget already exists in the DB
187 // (matched by w_unique). The MCP 'new'/'update' distinction is irrelevant
188 // to the server; always send 'add'.
189 'type' => 'add',
190 'title' => $title,
191 'content' => '',
192 'builder' => $builder,
193 'w_data' => wp_json_encode($json_data),
194 'w_unique' => $widget_id,
195 'w_image' => $w_image_body,
196 'w_imgext' => $w_imgext,
197 'w_version' => $w_version,
198 // Use the widget's actual version_detail array (mirrors the JS frontend which sends
199 // all_files.WcardData.widgetdata.version_detail). Sending serialize([]) was causing
200 // "Not allowed version number" from the cloud because an empty w_updates signals
201 // no version history, which the server treats as an invalid push.
202 'w_updates' => serialize($version_detail),
203 'r_id' => $r_id,
204 'unique_id' => get_option('wdkit_unique_id', ''),
205 ];
206
207 $response = wp_remote_post(
208 WDKIT_SERVER_API_URL . 'api/wp/save_widget',
209 [
210 'method' => 'POST',
211 'body' => $array_data,
212 'timeout' => 60,
213 ]
214 );
215
216 if (is_wp_error($response)) {
217 return ['success' => false, 'message' => $response->get_error_message()];
218 }
219
220 $status = wp_remote_retrieve_response_code($response);
221 $body = wp_remote_retrieve_body($response);
222 $data = json_decode($body, true);
223
224 // HTTP 200 with an empty/null body ( [], '', null ) means the cloud silently rejected
225 // the request — the account lacks marketplace publish rights, or a required metadata
226 // field is absent. (A missing thumbnail is NOT a cause: save_widget stores the image
227 // only when one is sent.) Guard covers both json_decode('[]') → [] and json_decode('') → null.
228 if (200 === (int) $status && ($data === null || (is_array($data) && empty($data)))) {
229 return [
230 'success' => false,
231 'message' => 'Invalid API response format: empty array. The cloud returned HTTP 200 but an empty response body. Check that the WDesignKit account has marketplace publish rights and that all required metadata fields (name, widget_id, widget_version) are present.',
232 'response' => ['raw' => $body],
233 ];
234 }
235
236 if (200 !== (int) $status || empty($data['success'])) {
237 $raw_msg = is_array($data) ? ($data['massage'] ?? $data['message'] ?? null) : null;
238 $msg = is_string($raw_msg) ? $raw_msg : (is_null($raw_msg) ? "Cloud returned status {$status}." : json_encode($raw_msg));
239
240 // Enrich version-rejection errors with actionable guidance.
241 // The cloud returns "Not allowed version number" when the incoming w_version
242 // matches or is lower than the already-published version for this widget.
243 if (stripos($msg, 'version') !== false && stripos($msg, 'not allowed') !== false) {
244 $msg .= " To fix: use wdesignkit/update-widget to bump widget_version (e.g. '1.0.0' → '1.0.1') and add a new entry to version_detail, then push again.";
245 }
246
247 return [
248 'success' => false,
249 'message' => $msg,
250 'response' => wdesignkit_mcp_ensure_object($data, $body),
251 ];
252 }
253
254 // Write back r_id and updated image URL to local JSON.
255 // $res_data IS the cloud's "data" object already ({imgurl, id}) — reading a further
256 // ['data'] level off of it (the previous code) always missed, so img_url and the id were
257 // silently '' / 0 on every push regardless of what the cloud sent. 'id' is kit_widgets.id
258 // (added server-side in SetSaveWidget) — the identifier download-widget / get-my-cloud-widgets
259 // / manage-workspace-widget all key on; it is NOT the same thing as the widgetdata.r_id this
260 // ability's own re-publish check reads (that column tracks copy lineage, not "my widget's id"),
261 // but this ability's response has always called it r_id, so keep that external name.
262 $res_data = is_array($data['data'] ?? null) ? $data['data'] : [];
263 $img_url = $res_data['imgurl'] ?? '';
264 $new_r_id = $res_data['id'] ?? ($res_data['r_id'] ?? 0);
265
266 $json_dirty = false;
267
268 if ($new_r_id) {
269 $json_data['widget_data']['widgetdata']['r_id'] = $new_r_id;
270 $json_dirty = true;
271 }
272 if ($img_url) {
273 $json_data['widget_data']['widgetdata']['w_image'] = $img_url;
274 $json_dirty = true;
275 }
276
277 // The write must run whenever EITHER field changed — previously it only ran inside the
278 // $img_url check, so an imageless push (now valid: the thumbnail pre-flight was removed
279 // in 86d3rc7q4) got a real r_id back from the cloud but never persisted it, leaving the
280 // widget permanently unrecognized as already-published on every later push/download call.
281 if ($json_dirty && $json_path) {
282 @file_put_contents($json_path, wp_json_encode($json_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
283 }
284
285 return [
286 'success' => true,
287 'message' => "Widget '{$title}' pushed to cloud." . ($new_r_id ? " Marketplace record ID: {$new_r_id}." : ''),
288 // Surfaced as a first-class field — callers previously had to dig it out of the raw
289 // cloud response shape (response.data.data.r_id), which QA correctly flagged as
290 // effectively undiscoverable.
291 'r_id' => (int) $new_r_id,
292 'response' => wdesignkit_mcp_ensure_object($data, $body),
293 ];
294 }
295