| 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 |
|