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 / templates / wdesignkit-save-template-full.php

wdesignkit-save-template-full.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/abilities/templates/wdesignkit-save-template-full.php

793 lines 43.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Save a builder page as a new WDesignKit cloud template AND set its
4 * site-side listing metadata (description, features, free/pro, thumbnail) in one call.
5 *
6 * The thumbnail is optional. When supplied it is base64-only, by design: there is no
7 * image_url/fetch-from-URL input. The ability never downloads an image from a page or an external
8 * host — it only ever uploads exact bytes the caller supplied directly, sent as a genuine
9 * multipart ImageFile field on updatetemplatesid (see the note further down — save_images /
10 * save-template-image does NOT set the real thumbnail, confirmed by reading the controller).
11 * With no image the template still saves and the cloud shows its own placeholder.
12 *
13 * short_description maps to the cloud's post_subcontent column (via updatetemplatesid's
14 * 'subcontent' param). Be aware what that column actually drives: it is NOT rendered as a
15 * separate "intro" line on the template listing. It is used server-side as the OG/Twitter meta
16 * description (ApplicationController sets ogDescription/twitterDescription from it), it is
17 * LIKE-searched for template search relevance, and the approval flow reads it as a required
18 * field — so leaving it empty can block a template from marketplace approval.
19 *
20 * Wraps two cloud calls that already exist separately:
21 * - POST /wp/save_template (same as wdesignkit/save-template) -> creates the template
22 * - POST /templates/update (TemplateController::updatetemplatesid) -> writes listing metadata
23 *
24 * /templates/update is NOT under the /wp/ prefix used by every other ability's cloud call,
25 * but it authenticates the same way (JWTAuth over the 'token' request param — see
26 * WdkitPluginController::WpGetUserData vs UserAuthController::loginCheck), so it accepts the
27 * plugin's token exactly like the /wp/* endpoints do.
28 *
29 * IMPORTANT — updatetemplatesid does a FULL, UNCONDITIONAL row overwrite: every column in its
30 * $NewData array is written on every call, falling back to a hardcoded default (usually '' or
31 * serialize([])) for any field the request omits. There is no partial-patch behaviour. Verified
32 * live: calling it with only {token, id, categorylist} wiped title, post_builder and plugins_id
33 * back to empty on an otherwise-correct row. So step 2 below is only invoked when the caller
34 * actually supplied optional metadata, and when it runs it RE-SENDS every field step 1 set
35 * (title, numeric Builder id, pluginlist, categorylist, type) so nothing gets clobbered. Field
36 * name/format quirks specific to this endpoint (confirmed by reading the controller):
37 * - Builder : numeric builder id (e.g. 1001 for Elementor), NOT the string "elementor".
38 * - pluginlist : comma-separated numeric ids ("1003,1005"), NOT a JSON array (categorylist
39 * uses the same comma-separated convention, already handled).
40 * - type : stored verbatim with no validation, but other queries in the same controller
41 * filter on the literal strings 'pagetemplate' / 'section' / 'websitekit' — NOT
42 * 'page' / 'section' / 'block'. Mapped below; there is no confirmed cloud
43 * equivalent for "block", so it passes through unmapped as a best effort.
44 */
45
46 declare(strict_types=1);
47
48 if (!defined('ABSPATH')) {
49 exit();
50 }
51
52 wp_register_ability('wdesignkit/save-template-full', [
53 'label' => __('Save WDesignKit Template (Full)', 'wdesignkit'),
54 'description' => __(
55 'Saves a page builder layout as a new WDesignKit cloud template, resolving category/plugins by name, and optionally sets its public listing metadata (live demo URL, short description, description, features, free/pro) and preview thumbnail in the same call. Everything beyond builder/data/name/template_type/category is optional and only written when you actually supply it — nothing is invented or defaulted. A thumbnail is set only by passing the image\'s exact bytes as image_base64; no image is ever fetched from a URL or the source page.',
56 'wdesignkit',
57 ),
58 'category' => 'wdesignkit',
59 'input_schema' => [
60 'type' => 'object',
61 'properties' => [
62 'builder' => [
63 'type' => 'string',
64 'description' => 'Builder the template was authored in.',
65 'enum' => ['elementor', 'gutenberg'],
66 ],
67 'data' => [
68 'type' => 'string',
69 'description' => 'Serialized builder data. For Elementor pass the JSON Elementor exporter output. For Gutenberg pass the serialized block markup or block JSON. Do NOT base64 encode it.',
70 ],
71 'post_id' => [
72 'type' => 'string',
73 'description' => 'Source post/page ID. Used to capture nxt-* custom meta alongside the saved template.',
74 ],
75 'name' => [
76 'type' => 'string',
77 'description' => 'Template display name.',
78 ],
79 'template_type' => [
80 'type' => 'string',
81 'description' => 'Template type (e.g. "page", "section", "block").',
82 ],
83 'category' => [
84 'type' => ['string', 'array'],
85 'description' => 'Template category NAME(s). A template can belong to MULTIPLE categories (the admin UI\'s Category field is a multi-select): pass either a comma-separated string ("Sport Fitness, About") or an array (["Sport Fitness", "About"]). Each name is resolved against the cloud\'s category list and must match an existing name (case-insensitive); an unmatched name fails with the list of valid names. Beware that a few names are duplicated in the cloud (Portfolio, Pricing, Team, Blog, Services) — those resolve to whichever row comes last.',
86 'items' => ['type' => 'string'],
87 ],
88 'tags' => [
89 'type' => ['string', 'array'],
90 'description' => 'Optional tag NAME(s) for the template — comma-separated string ("fitness, gym") or array. Resolved against the cloud\'s existing tag list (kit_tags_list); tags CANNOT be created on the fly, so any name that does not already exist is skipped and reported back in the response message. Tags are only settable through the metadata step — the create endpoint always initialises tags_id empty.',
91 'items' => ['type' => 'string'],
92 ],
93 'plugins' => [
94 'type' => ['array', 'string'],
95 'description' => 'Optional list of plugin/builder NAMES this template requires (e.g. "WooCommerce", "Elementor Pro"). Accepts an array or a comma-separated string, same as category and tags. Resolved to cloud plugin IDs automatically — do not pass numeric IDs here.',
96 'items' => ['type' => 'string'],
97 ],
98 'demo_url' => [
99 'type' => 'string',
100 'description' => 'Optional live demo / preview URL for the template — normally the public URL of the source page itself. Stored in the cloud\'s post_url column (updatetemplatesid\'s \'url\' param). NOTE: post_url is the only general-purpose URL column this endpoint can write (products_url has no write param; elementor_url/gutenberg_url/figma_url are builder-specific), so this is where a demo link belongs. Must be a publicly reachable URL to be useful — a local dev URL like http://site.local/page/ will be stored but will not resolve for anyone else.',
101 ],
102 'elementor_url' => [
103 'type' => 'string',
104 'description' => 'Optional CROSS-LINK to the Elementor version of this same template (stored in the elementor_url column). Set this on a GUTENBERG template to point visitors at its Elementor twin — the admin UI only exposes this field when post_builder is Gutenberg (1002). Leave empty when no Elementor equivalent exists.',
105 ],
106 'gutenberg_url' => [
107 'type' => 'string',
108 'description' => 'Optional CROSS-LINK to the Gutenberg version of this same template (stored in the gutenberg_url column). Set this on an ELEMENTOR template to point visitors at its Gutenberg twin — the admin UI only exposes this field when post_builder is Elementor (1001). Leave empty when no Gutenberg equivalent exists.',
109 ],
110 'figma_url' => [
111 'type' => 'string',
112 'description' => 'Optional link to the Figma version of this template (stored in the figma_url column). Shown for every builder in the admin UI, unlike the two builder cross-links.',
113 ],
114 'short_description' => [
115 'type' => 'string',
116 'description' => 'Optional one-sentence short description (stored as the cloud\'s post_subcontent). This is NOT rendered as a separate intro line on the template listing — it is used as the OG/Twitter meta description for the template\'s public page, is search-indexed for template search, and the marketplace approval flow treats it as a required field. Keep it to a single grounded sentence; omit rather than inventing filler.',
117 ],
118 'description' => [
119 'type' => 'string',
120 'description' => 'Optional longer (2-3 sentence) description shown on the template detail page. Only pass this if you actually have grounded content for it — omit entirely rather than inventing filler; when omitted, the field is left untouched (not written).',
121 ],
122 'features' => [
123 'type' => 'array',
124 'description' => 'Optional list of feature bullets, each grounded in a section/widget actually present on the page (e.g. "Pricing table with 3 tiers"). Pass plain strings — the ability wraps them in <ul><li> markup, because the cloud stores this as rich text (the admin editor uses a Quill rich-text field for it), so a plain newline-joined string would render as one run-on paragraph. Omit entirely rather than inventing features not present on the page.',
125 'items' => ['type' => 'string'],
126 ],
127 'is_pro' => [
128 'type' => 'boolean',
129 'description' => 'Optional. Whether this template requires a paid/PRO plugin. Only pass this if you can actually tell from the resolved "plugins" — omit if unknown; when omitted, the cloud default (free) is left as-is.',
130 ],
131 'image_base64' => [
132 'type' => 'string',
133 'description' => 'Optional preview thumbnail image, supplied inline as base64 (raw base64 or a data:image/...;base64,... URI) — the bytes of a file the user uploaded/provided. Must be PNG, JPEG or WebP and under 2 MB, matching what the admin UI accepts for this same field. These exact bytes are sent as the ImageFile multipart field on updatetemplatesid (NOT the save_images/save-template-image endpoint, which only registers an asset name and never sets the actual thumbnail). There is deliberately no image_url/fetch-from-URL option: this ability never fetches an image from the page or any external/public URL — it only ever uploads bytes the caller explicitly provided. Omit it and the template still saves, just with no preview image (the cloud falls back to its own placeholder), which can be set later.',
134 ],
135 'image_filename' => [
136 'type' => 'string',
137 'description' => 'Optional original filename of the uploaded image (e.g. "thumbnail.png"), used to preserve the real file extension in the multipart upload. When omitted, the extension is inferred from a data:image/...;base64,... URI if present, otherwise defaults to jpg.',
138 ],
139 ],
140 'required' => ['builder', 'data', 'name', 'template_type', 'category'],
141 'additionalProperties' => false,
142 ],
143 'output_schema' => [
144 'type' => 'object',
145 'properties' => [
146 'success' => ['type' => 'boolean'],
147 'message' => ['type' => 'string'],
148 'template_id' => ['type' => 'string'],
149 'save_response' => ['type' => ['object', 'array']],
150 'update_response' => ['type' => ['object', 'array']],
151 ],
152 ],
153 'execute_callback' => 'wdesignkit_mcp_save_template_full',
154 'permission_callback' => 'wdesignkit_mcp_permission_callback',
155 'meta' => [
156 'public' => true,
157 'show_in_rest' => true,
158 'mcp' => ['public' => true],
159 'annotations' => [
160 'instructions' => implode("\n", [
161 'Two-step ability: (1) creates the cloud template via save_template, (2) immediately writes its',
162 'listing metadata via the cloud\'s /templates/update endpoint. If step 2 fails, the template from',
163 'step 1 still exists — the response tells you the template_id either way so you can retry the',
164 'metadata write with wdesignkit/update-template-metadata-style calls, or fix data and call again.',
165 'category, tags and plugins are all matched by NAME against the cloud\'s live lists',
166 '(template/filter/options -> categorydb / tagdb / plugindb). An unmatched CATEGORY is fatal',
167 '(it would file the template somewhere the caller never chose); unmatched PLUGIN names are',
168 'skipped since plugins are optional; unmatched TAG names are skipped but reported back in',
169 'the response message, because tags cannot be created on the fly.',
170 'category and tags both accept multiple values (comma-separated string or array).',
171 'demo_url is the template\'s live demo/preview link (usually the source page\'s own public',
172 'URL) and writes post_url. Only useful if publicly reachable — a local dev URL is stored',
173 'but resolves for nobody else.',
174 'elementor_url / gutenberg_url are CROSS-LINKS between the two builder versions of the same',
175 'design: on an Elementor template set gutenberg_url, on a Gutenberg template set',
176 'elementor_url. figma_url is the Figma equivalent and applies to either builder. All three',
177 'are optional — omit any that has no counterpart.',
178 'demo_url, short_description, description, features and is_pro are all OPTIONAL: pass only',
179 'what you genuinely know from the page/template content. Do not invent values to fill them in — omit',
180 'the field instead, and it is left untouched on the cloud rather than overwritten with',
181 'guessed content.',
182 'short_description writes post_subcontent, which is NOT shown as a separate intro line on',
183 'the listing — it becomes the OG/Twitter meta description, feeds template search, and the',
184 'marketplace approval flow treats it as a required field, so it is worth filling in for any',
185 'template intended for public release.',
186 'image_base64 is OPTIONAL — the template saves fine without a thumbnail (the cloud shows',
187 'its own placeholder, and an image can be added later). When the user HAS provided an',
188 'image, read the file and base64-encode it yourself; do NOT ask them for a URL or for',
189 'base64 they have to produce by hand. There is no image_url/fetch option by design: this',
190 'ability never downloads an image from a URL or the source page. The exact bytes supplied',
191 'become the thumbnail via updatetemplatesid\'s ImageFile multipart field — never',
192 'save_images/save-template-image, which only registers an asset name and never actually',
193 'sets post_image. Pass image_filename too when known, to preserve the real file extension.',
194 ]),
195 'readonly' => false,
196 'destructive' => false,
197 'idempotent' => false,
198 ],
199 ],
200 ]);
201
202 function wdesignkit_mcp_save_template_full(array $input): array {
203 set_time_limit(150);
204
205 $auth = wdesignkit_mcp_template_get_auth();
206 if (empty($auth['logged_in'])) {
207 return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in.'];
208 }
209 $token = $auth['token'];
210
211 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
212 $data = (string) ($input['data'] ?? '');
213 $post_id = sanitize_text_field((string) ($input['post_id'] ?? ''));
214 $name = sanitize_text_field((string) ($input['name'] ?? ''));
215 $template_type = sanitize_text_field((string) ($input['template_type'] ?? ''));
216 // category accepts one or many names, as an array or a comma-separated string.
217 $category_raw = $input['category'] ?? '';
218 $category_in = [];
219 foreach (is_array($category_raw) ? $category_raw : explode(',', (string) $category_raw) as $cat_name) {
220 $cat_name = sanitize_text_field(trim((string) $cat_name));
221 if ($cat_name !== '') {
222 $category_in[] = $cat_name;
223 }
224 }
225 // Accept a comma-separated string as well as an array, exactly as the sibling category and
226 // tags inputs do. Requiring an array here meant a string was silently dropped — the save
227 // reported success with no plugin dependency recorded (ClickUp 86d41ceke).
228 $plugins_raw = $input['plugins'] ?? null;
229 $plugins_in = is_array($plugins_raw)
230 ? $plugins_raw
231 : (('' === (string) $plugins_raw || null === $plugins_raw) ? [] : explode(',', (string) $plugins_raw));
232 $plugins_in = array_values(array_filter(array_map('trim', array_map('strval', $plugins_in)), static fn($p) => '' !== $p));
233
234 // tags accepts one or many names, as an array or a comma-separated string.
235 $tags_raw = $input['tags'] ?? '';
236 $tags_in = [];
237 foreach (is_array($tags_raw) ? $tags_raw : explode(',', (string) $tags_raw) as $tag_name) {
238 $tag_name = sanitize_text_field(trim((string) $tag_name));
239 if ($tag_name !== '') {
240 $tags_in[] = $tag_name;
241 }
242 }
243 $demo_url = array_key_exists('demo_url', $input) ? sanitize_url((string) $input['demo_url']) : null;
244 // Builder cross-links: on an Elementor template you set gutenberg_url (and vice versa) so the
245 // two builder versions of the same design point at each other. figma_url applies to both.
246 $elementor_url = array_key_exists('elementor_url', $input) ? sanitize_url((string) $input['elementor_url']) : null;
247 $gutenberg_url = array_key_exists('gutenberg_url', $input) ? sanitize_url((string) $input['gutenberg_url']) : null;
248 $figma_url = array_key_exists('figma_url', $input) ? sanitize_url((string) $input['figma_url']) : null;
249 // post_subcontent / post_content / post_feature are edited through QuillEditor rich-text
250 // fields in the admin UI (packs/view/[id]/page.jsx lines 357, 362, 367) and the cloud
251 // html_entity_decode()s the feature value — so these columns hold HTML, not plain text.
252 // wp_kses_post keeps safe markup a caller supplies instead of flattening it the way
253 // sanitize_textarea_field would.
254 $short_desc = array_key_exists('short_description', $input) ? wp_kses_post((string) $input['short_description']) : null;
255 $description = array_key_exists('description', $input) ? wp_kses_post((string) $input['description']) : null;
256 $features_in = is_array($input['features'] ?? null) ? $input['features'] : null;
257 $is_pro = array_key_exists('is_pro', $input) ? (bool) $input['is_pro'] : null;
258 $image_base64 = (string) ($input['image_base64'] ?? '');
259 $image_filename = sanitize_file_name((string) ($input['image_filename'] ?? ''));
260
261 if (!in_array($builder, ['elementor', 'gutenberg'], true)) {
262 return ['success' => false, 'message' => 'builder must be "elementor" or "gutenberg".'];
263 }
264 if ($data === '' || $name === '' || $template_type === '' || empty($category_in)) {
265 return ['success' => false, 'message' => 'data, name, template_type and category are all required.'];
266 }
267
268 // The thumbnail is OPTIONAL: when no image_base64 is supplied the template still saves, just
269 // without a preview image (the cloud shows its own placeholder). When one IS supplied it is
270 // decoded here — BEFORE step 1 — so invalid base64 fails fast instead of leaving a saved
271 // template behind that never got its thumbnail.
272 //
273 // updatetemplatesid's ImageFile field requires a genuine multipart file part (Laravel calls
274 // UploadedFile::move() on it) — save_images / save-template-image only registers an asset
275 // name and never sets the real post_image, confirmed by reading the controller directly.
276 $image_bytes = '';
277 $image_ext = 'jpg';
278 if ($image_base64 !== '') {
279 $b64 = $image_base64;
280 if (preg_match('#^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$#s', $b64, $m)) {
281 $image_ext = strtolower($m[1]) === 'jpeg' ? 'jpg' : strtolower($m[1]);
282 $b64 = $m[2];
283 }
284 $b64 = preg_replace('/\s+/', '', (string) $b64);
285 $decoded = ($b64 !== '') ? base64_decode($b64, true) : false;
286 if ($decoded === false || $decoded === '') {
287 return ['success' => false, 'message' => 'image_base64 was provided but is not valid base64 image data.'];
288 }
289 $image_bytes = $decoded;
290
291 // An explicit image_filename's extension takes precedence over data-URI sniffing —
292 // it reflects the real uploaded file, not a guess.
293 if ($image_filename !== '') {
294 $filename_ext = strtolower((string) pathinfo($image_filename, PATHINFO_EXTENSION));
295 if ($filename_ext !== '') {
296 $image_ext = $filename_ext === 'jpeg' ? 'jpg' : $filename_ext;
297 }
298 }
299
300 // Match the constraints the admin UI enforces on this same ImageFile field
301 // (packs/view/[id]/page.jsx: accept="image/png, image/jpeg, image/webp" and a 2 MB cap in
302 // handleChangeImage). Checking here gives a clear, actionable error instead of relying on
303 // whatever the cloud does with an oversized or unsupported file.
304 if (!in_array($image_ext, ['png', 'jpg', 'webp'], true)) {
305 return [
306 'success' => false,
307 'message' => sprintf(
308 'Unsupported thumbnail format "%s". The template featured image accepts PNG, JPEG or WebP only.',
309 $image_ext
310 ),
311 ];
312 }
313 if (strlen($image_bytes) > 2 * 1024 * 1024) {
314 return [
315 'success' => false,
316 'message' => sprintf(
317 'Thumbnail is %.2f MB — the template featured image must be under 2 MB. Resize or recompress it and try again.',
318 strlen($image_bytes) / 1048576
319 ),
320 ];
321 }
322 }
323
324 // Resolve category (and plugin) names against the cloud's live lists.
325 $filter_options = wdesignkit_mcp_template_cloud_call('template/filter/options', [
326 'token' => $token,
327 ], 'form');
328
329 if (empty($filter_options['success'])) {
330 return [
331 'success' => false,
332 'message' => $filter_options['message'] ?? $filter_options['massage'] ?? 'Failed to load the cloud category/plugin list.',
333 ];
334 }
335
336 $category_rows = (array) ($filter_options['data']['categorydb'] ?? []);
337 $category_names = [];
338 foreach ($category_rows as $row) {
339 if (is_array($row)) {
340 $category_names[] = (string) ($row['term_name'] ?? '');
341 }
342 }
343
344 $category_ids = [];
345 $unmatched = [];
346 foreach ($category_in as $wanted) {
347 $found = null;
348 foreach ($category_rows as $row) {
349 if (!is_array($row)) {
350 continue;
351 }
352 if (strcasecmp((string) ($row['term_name'] ?? ''), $wanted) === 0) {
353 $found = (int) ($row['term_id'] ?? 0);
354 }
355 }
356 if ($found === null || $found <= 0) {
357 $unmatched[] = $wanted;
358 } else {
359 $category_ids[] = $found;
360 }
361 }
362 $category_ids = array_values(array_unique($category_ids));
363
364 // Any unmatched name is fatal: silently dropping a category the caller asked for would file
365 // the template somewhere they never chose.
366 if (!empty($unmatched)) {
367 return [
368 'success' => false,
369 'message' => sprintf(
370 'Unknown categor%s: %s. Valid categories: %s',
371 count($unmatched) === 1 ? 'y' : 'ies',
372 implode(', ', array_map(static fn($u) => '"' . $u . '"', $unmatched)),
373 implode(', ', array_slice(array_filter($category_names), 0, 60))
374 ),
375 ];
376 }
377
378 // SetSaveTemplate's 'category' param takes a SINGLE id (serialize([(int) $Category])), so
379 // step 1 can only seed the first one; step 2's comma-separated 'categorylist' sets them all.
380 $category_id = $category_ids[0];
381
382 $plugin_rows = (array) ($filter_options['data']['plugindb'] ?? []);
383 $plugin_ids = [];
384 if (!empty($plugins_in)) {
385 foreach ($plugins_in as $plugin_name) {
386 $plugin_name = (string) $plugin_name;
387 foreach ($plugin_rows as $row) {
388 if (!is_array($row)) {
389 continue;
390 }
391 if (strcasecmp((string) ($row['plugin_name'] ?? ''), $plugin_name) === 0) {
392 $pid = (int) ($row['p_id'] ?? 0);
393 if ($pid > 0) {
394 $plugin_ids[] = $pid;
395 }
396 break;
397 }
398 }
399 }
400 $plugin_ids = array_values(array_unique($plugin_ids));
401 }
402
403 // Resolve tag names against the cloud's existing tag list. Tags cannot be created here, so
404 // unknown names are collected and surfaced in the response rather than dropped silently.
405 $tag_rows = (array) ($filter_options['data']['tagdb'] ?? []);
406 $tag_ids = [];
407 $tags_unmatched = [];
408 foreach ($tags_in as $wanted) {
409 $found = null;
410 foreach ($tag_rows as $row) {
411 if (!is_array($row)) {
412 continue;
413 }
414 if (strcasecmp((string) ($row['tag_name'] ?? ''), $wanted) === 0) {
415 $found = (int) ($row['tag_id'] ?? 0);
416 break;
417 }
418 }
419 if ($found === null || $found <= 0) {
420 $tags_unmatched[] = $wanted;
421 } else {
422 $tag_ids[] = $found;
423 }
424 }
425 $tag_ids = array_values(array_unique($tag_ids));
426
427 // Resolve the numeric builder id updatetemplatesid expects for its own 'Builder' param
428 // (post_builder is written via intval($Builder) — a string like "elementor" would intval() to 0).
429 $builder_rows = (array) ($filter_options['data']['builderdb'] ?? []);
430 $builder_id = 0;
431 foreach ($builder_rows as $row) {
432 if (!is_array($row)) {
433 continue;
434 }
435 if (strcasecmp((string) ($row['plugin_name'] ?? ''), $builder) === 0) {
436 $builder_id = (int) ($row['p_id'] ?? 0);
437 break;
438 }
439 }
440
441 // updatetemplatesid stores 'type' verbatim but OTHER queries in the same controller filter on
442 // the literal strings 'pagetemplate' / 'section' / 'websitekit' — not 'page'/'section'/'block'.
443 // There is no confirmed cloud equivalent for "block"; pass it through unmapped as a best effort.
444 $cloud_type_map = ['page' => 'pagetemplate', 'section' => 'section'];
445 $cloud_type = $cloud_type_map[$template_type] ?? $template_type;
446
447 // Wrap the payload in the envelope the cloud, the importer and the editor JS all expect —
448 // the same one the Save Template UI builds in main_save_template.js:
449 // { file_type, title, page_id, content, el_type, settings } with the layout in `content`.
450 //
451 // Sending the raw layout as `data` instead broke both builders, in different ways:
452 // Elementor — _elementor_data decodes to a LIST, and adding the string key 'custom_meta' to
453 // a list makes wp_json_encode() emit an object, so the indices became "0","1",…
454 // Gutenberg — block markup is a STRING, so json_decode() returned null, the is_array()
455 // branch was skipped, and the markup was stored bare with no envelope at all.
456 // The editor then runs JSON.parse() on it and throws.
457 // Building the envelope first fixes both: custom_meta now goes onto an OBJECT, where an extra
458 // key cannot reshape `content`.
459 if ('elementor' === $builder) {
460 $content = json_decode($data, true);
461 if (!is_array($content)) {
462 return [
463 'success' => false,
464 'message' => 'data is not valid Elementor JSON — pass the _elementor_data export (a JSON array), not markup.',
465 ];
466 }
467 } else {
468 $decoded = json_decode($data, true);
469 $content = is_array($decoded) ? $decoded : $data;
470
471 }
472
473 $settings = [];
474 if ($post_id !== '') {
475 $page_settings = get_post_meta((int) $post_id, '_elementor_page_settings', true);
476 if (is_array($page_settings) && !empty($page_settings)) {
477 $settings = $page_settings;
478 }
479 }
480
481 $envelope = [
482 'file_type' => 'elementor' === $builder ? 'elementor' : 'wp_block',
483 'title' => $name,
484 'page_id' => $post_id !== '' ? (int) $post_id : 0,
485 'content' => $content,
486 'el_type' => '',
487 'settings' => $settings,
488 ];
489
490 // The Plus Addons resolves its global button styles, radii and shadows from the kit by
491 // `_id`. Without those definitions travelling with the template, an imported section keeps
492 // the reference but loses the styling. Scoped to what this layout actually references, so a
493 // section carries its own globals rather than a copy of the whole kit.
494 $tp_globals = ('elementor' === $builder && is_array($content)) ? wdesignkit_mcp_tp_globals($content) : [];
495 if (!empty($tp_globals)) {
496 $envelope['tp_globals'] = $tp_globals;
497
498 // A Plus global can reference an Elementor global colour or font of its own. Those refs
499 // live in the lists rather than the layout, so they need capturing separately or the
500 // button style arrives pointing at an id the destination kit does not have.
501 $tp_refs = wdesignkit_mcp_tp_global_refs($tp_globals);
502 if (!empty($tp_refs['color']) || !empty($tp_refs['typography'])) {
503 $envelope['tp_global_refs'] = $tp_refs;
504 }
505 }
506
507 if ($post_id !== '') {
508 $custom_fields = [];
509 foreach ((array) get_post_custom($post_id) as $key => $value) {
510 if (is_string($key) && str_contains($key, 'nxt-')) {
511 $custom_fields[$key] = $value;
512 }
513 }
514 if (!empty($custom_fields)) {
515 $envelope['custom_meta'] = $custom_fields;
516 }
517 }
518
519 $data = wp_json_encode($envelope);
520
521 // Step 1: create the bare template. SetSaveTemplate also accepts a single 'category' id at
522 // creation time (serialize([(int) $Category]) into terms_id) — send it here so category is
523 // set atomically with the template rather than depending on step 2 succeeding afterward.
524 //
525 // IMPORTANT: SetSaveTemplate reads 'template_type' and 'wp_post_type' at creation — it does
526 // NOT read a 'type' key at all:
527 // $TemplateType = $request->get('template_type');
528 // 'type' => !empty($TemplateType) ? $TemplateType : 'pagetemplate',
529 // (WdkitPluginController lines 171 / 252). 'type' is kept below only because it is harmless
530 // and matches step 2's own param name.
531 //
532 // template_type carries the ALREADY-MAPPED $cloud_type, not the caller's raw value. Sending
533 // the raw value made step 1 store e.g. type="page" verbatim, which the cloud's own queries
534 // never match (they filter on the literals 'section' / 'pagetemplate' — see the whereIn at
535 // line 574). That was invisible whenever step 2 ran, since step 2 rewrote type to the mapped
536 // value — but a bare save (no thumbnail, no metadata) skips step 2 and the bad value stuck.
537 $save_args = [
538 'token' => $token,
539 'data' => $data,
540 'post_id' => $post_id,
541 'builder' => $builder,
542 // Cloud reads the display name from 'title', not 'name' — see wdesignkit-save-template.php.
543 'title' => $name,
544 'name' => $name,
545 'type' => $cloud_type,
546 'template_type' => $cloud_type,
547 'wp_post_type' => 'page',
548 'category' => $category_id,
549 ];
550 if (!empty($plugin_ids)) {
551 $save_args['plugins'] = wp_json_encode($plugin_ids);
552 }
553
554 $save_response = wdesignkit_mcp_template_cloud_call('save_template', $save_args, 'json');
555
556 if (empty($save_response['success'])) {
557 return [
558 'success' => false,
559 'message' => $save_response['message'] ?? 'Failed to save template.',
560 'save_response' => $save_response,
561 ];
562 }
563
564 $template_id = (string) (
565 $save_response['id']
566 ?? $save_response['template_id']
567 ?? $save_response['data']['id']
568 ?? ''
569 );
570
571 if ($template_id === '') {
572 return [
573 'success' => false,
574 'message' => 'Template was saved but the cloud response did not include an id — cannot write listing metadata. Check save_response.',
575 'save_response' => $save_response,
576 ];
577 }
578
579 // Step 2 runs only when there is actually something to write (metadata and/or a thumbnail).
580 // It is SKIPPED otherwise, because updatetemplatesid overwrites the whole row unconditionally:
581 // calling it with nothing new to say would wipe the fields step 1 just set.
582 // More than one category also forces step 2: step 1 could only seed the first id, so the
583 // extras would be silently lost on an otherwise-bare save.
584 $has_metadata = count($category_ids) > 1
585 || !empty($tag_ids)
586 || ($demo_url !== null && $demo_url !== '')
587 || ($elementor_url !== null && $elementor_url !== '')
588 || ($gutenberg_url !== null && $gutenberg_url !== '')
589 || ($figma_url !== null && $figma_url !== '')
590 || ($short_desc !== null && $short_desc !== '')
591 || ($description !== null && $description !== '')
592 || ($features_in !== null && !empty($features_in))
593 || ($is_pro !== null)
594 || ($image_bytes !== '');
595
596 if (!$has_metadata) {
597 return [
598 'success' => true,
599 'message' => 'Template saved. No thumbnail or optional metadata was supplied, so the metadata-update step was skipped (it would otherwise overwrite the whole row).',
600 'template_id' => $template_id,
601 'save_response' => $save_response,
602 ];
603 }
604
605 // Every field step 1 set is RE-SENT here in updatetemplatesid's own param names/formats —
606 // this endpoint overwrites the entire row, so anything omitted here reverts to a hardcoded
607 // default (see the file-level note above). description/features/is_pro are added on top.
608 //
609 // Re-sending only the handful of keys this ability knows about was not enough: every OTHER
610 // column still reverted to a controller default, so a save that supplied nothing but two
611 // categories silently reset status, post_display, keywords, ai_compatible, color_palette,
612 // color_palette_id, Filename, OldFileName, views, Download, average and collection — post
613 // status and filename among them (ClickUp 86d41cd06). Read the row and start from its current
614 // values, exactly as set-template-link does, then overlay only what this call is changing, so
615 // both callers of this destructive endpoint behave the same way.
616 if (!function_exists('wdesignkit_mcp_template_preserve_args') || !function_exists('wdesignkit_mcp_fetch_template_row')) {
617 $link_ability_path = __DIR__ . '/wdesignkit-set-template-link.php';
618 if (file_exists($link_ability_path)) {
619 require_once $link_ability_path;
620 }
621 }
622
623 $preserved = [];
624 if (function_exists('wdesignkit_mcp_fetch_template_row') && function_exists('wdesignkit_mcp_template_preserve_args')) {
625 $existing_row = wdesignkit_mcp_fetch_template_row($token, (int) $template_id);
626 if (is_array($existing_row)) {
627 $preserved = wdesignkit_mcp_template_preserve_args($existing_row, $token);
628 }
629 }
630
631 $update_args = array_merge($preserved, [
632 'token' => $token,
633 'id' => $template_id,
634 'title' => $name,
635 'Builder' => $builder_id,
636 'type' => $cloud_type,
637 ]);
638
639 if (!empty($category_ids)) {
640 // Comma-separated ids — updatetemplatesid explode(",")s this into terms_id.
641 // Only overridden when this call actually resolved categories, so a save that supplies
642 // none keeps the ones already on the template instead of clearing them.
643 $update_args['categorylist'] = implode(',', $category_ids);
644 }
645 if (!empty($plugin_ids)) {
646 $update_args['pluginlist'] = implode(',', $plugin_ids);
647 }
648 if (!empty($tag_ids)) {
649 // Comma-separated ids — updatetemplatesid explode(",")s this into tags_id.
650 $update_args['tagslist'] = implode(',', $tag_ids);
651 }
652 if ($demo_url !== null && $demo_url !== '') {
653 // Cloud param is 'url' -> post_url column (the live demo / preview link).
654 $update_args['url'] = $demo_url;
655 }
656 if ($elementor_url !== null && $elementor_url !== '') {
657 $update_args['elementor_url'] = $elementor_url;
658 }
659 if ($gutenberg_url !== null && $gutenberg_url !== '') {
660 $update_args['gutenberg_url'] = $gutenberg_url;
661 }
662 if ($figma_url !== null && $figma_url !== '') {
663 $update_args['figma_url'] = $figma_url;
664 }
665 if ($short_desc !== null && $short_desc !== '') {
666 // Cloud param is 'subcontent' -> post_subcontent column.
667 $update_args['subcontent'] = $short_desc;
668 }
669 if ($description !== null && $description !== '') {
670 $update_args['description'] = $description;
671 }
672 if ($features_in !== null && !empty($features_in)) {
673 // post_feature is a QuillEditor rich-text field in the admin UI, and the cloud runs
674 // html_entity_decode() on it — a newline-joined plain string renders as one run-on
675 // paragraph there, not a list. Emit real list markup so features display as bullets
676 // and stay editable as a list in the admin editor.
677 $items = '';
678 foreach ($features_in as $feature) {
679 $feature = trim(wp_strip_all_tags((string) $feature));
680 if ($feature !== '') {
681 $items .= '<li>' . esc_html($feature) . '</li>';
682 }
683 }
684 if ($items !== '') {
685 $update_args['feature'] = '<ul>' . $items . '</ul>';
686 }
687 }
688 if ($is_pro !== null) {
689 $update_args['freepro'] = $is_pro ? 'pro' : 'free';
690 }
691
692 // /templates/update is not under the /wp/ prefix that wdesignkit_mcp_template_cloud_call()
693 // assumes for 'form' mode, so post to it directly — it authenticates the same way (JWTAuth
694 // over the 'token' field) as every /wp/* endpoint.
695 if (!defined('WDKIT_SERVER_API_URL')) {
696 return [
697 'success' => false,
698 'message' => 'Template saved (id ' . $template_id . ') but WDesignKit plugin core not loaded — could not write listing metadata.',
699 'template_id' => $template_id,
700 'save_response' => $save_response,
701 ];
702 }
703
704 if ($image_bytes !== '') {
705 // updatetemplatesid's ImageFile field requires a genuine multipart file part — WordPress's
706 // wp_remote_post() does not build multipart/form-data from a plain array, so it's built by
707 // hand here: every scalar in $update_args becomes a normal form field, plus one file part
708 // named ImageFile carrying the raw bytes.
709 $boundary = 'wdk' . wp_generate_password(24, false);
710 $eol = "\r\n";
711 $body = '';
712 foreach ($update_args as $field => $value) {
713 $body .= '--' . $boundary . $eol;
714 $body .= 'Content-Disposition: form-data; name="' . $field . '"' . $eol . $eol;
715 $body .= $value . $eol;
716 }
717 $upload_filename = $image_filename !== '' ? $image_filename : ('template-' . $template_id . '.' . $image_ext);
718 $body .= '--' . $boundary . $eol;
719 $body .= 'Content-Disposition: form-data; name="ImageFile"; filename="' . $upload_filename . '"' . $eol;
720 $body .= 'Content-Type: image/' . ($image_ext === 'jpg' ? 'jpeg' : $image_ext) . $eol . $eol;
721 $body .= $image_bytes . $eol;
722 $body .= '--' . $boundary . '--' . $eol;
723
724 $update_http = wp_remote_post(
725 WDKIT_SERVER_API_URL . 'api/templates/update',
726 [
727 'method' => 'POST',
728 'headers' => ['Content-Type' => 'multipart/form-data; boundary=' . $boundary],
729 'body' => $body,
730 'timeout' => 60,
731 ]
732 );
733 } else {
734 // No thumbnail supplied — a plain form-encoded POST is enough (no file part to build).
735 $update_http = wp_remote_post(
736 WDKIT_SERVER_API_URL . 'api/templates/update',
737 [
738 'method' => 'POST',
739 'body' => $update_args,
740 'timeout' => 60,
741 ]
742 );
743 }
744
745 if (is_wp_error($update_http)) {
746 return [
747 'success' => false,
748 'message' => 'Template saved (id ' . $template_id . ') but the metadata update request failed: ' . $update_http->get_error_message(),
749 'template_id' => $template_id,
750 'save_response' => $save_response,
751 ];
752 }
753
754 $update_code = wp_remote_retrieve_response_code($update_http);
755 $update_body = wp_remote_retrieve_body($update_http);
756 $update_data = json_decode($update_body, true);
757
758 // Same failure mode wdesignkit/update-template already guards against: a 200 with an
759 // empty/non-JSON body cannot be treated as confirmed success.
760 if (200 !== (int) $update_code || (!is_array($update_data) && $update_body === '')) {
761 return [
762 'success' => false,
763 'message' => 'Template saved (id ' . $template_id . ') but listing metadata update returned an unconfirmed response (status ' . $update_code . '). Verify manually and retry if needed.',
764 'template_id' => $template_id,
765 'save_response' => $save_response,
766 'update_response' => wdesignkit_mcp_ensure_object($update_data, $update_body),
767 ];
768 }
769
770 $message = $update_data['message'] ?? $update_data['massage'] ?? 'Template saved and listing metadata updated.';
771
772 // Never let a dropped tag pass unreported — the caller asked for it and did not get it.
773 if (!empty($tags_unmatched)) {
774 $message .= sprintf(
775 ' Note: %d tag(s) were skipped because they do not exist in the cloud tag list and cannot be created here: %s.',
776 count($tags_unmatched),
777 implode(', ', array_map(static fn($t) => '"' . $t . '"', $tags_unmatched))
778 );
779 }
780
781 return [
782 // Require an explicit confirmation rather than defaulting to true. A 200 with a non-JSON
783 // body — a WAF or proxy interception page, which this plugin's own comments record as a
784 // real occurrence — leaves $update_data null, and the old `?? true` then reported a
785 // metadata write that never happened (ClickUp 86d41cczy).
786 'success' => is_array($update_data) && !empty($update_data['success']),
787 'message' => $message,
788 'template_id' => $template_id,
789 'save_response' => $save_response,
790 'update_response' => wdesignkit_mcp_ensure_object($update_data, $update_body),
791 ];
792 }
793