| 1 |
<?php |
| 2 |
/** |
| 3 |
* Ability: Save the contents of a builder page as a new WDesignKit cloud template. |
| 4 |
*/ |
| 5 |
|
| 6 |
declare(strict_types=1); |
| 7 |
|
| 8 |
if (!defined('ABSPATH')) { |
| 9 |
exit(); |
| 10 |
} |
| 11 |
|
| 12 |
wp_register_ability('wdesignkit/save-template', [ |
| 13 |
'label' => __('Save WDesignKit Template', 'wdesignkit'), |
| 14 |
'description' => __( |
| 15 |
'Saves a page builder layout to the user\'s WDesignKit cloud library as a new template. Provide the full builder data payload (JSON string for Gutenberg, base64-decoded Elementor export for Elementor) plus the source post_id so any nxt-* custom meta is captured alongside it.', |
| 16 |
'wdesignkit', |
| 17 |
), |
| 18 |
'category' => 'wdesignkit', |
| 19 |
'input_schema' => [ |
| 20 |
'type' => 'object', |
| 21 |
'properties' => [ |
| 22 |
'builder' => [ |
| 23 |
'type' => 'string', |
| 24 |
'description' => 'Builder the template was authored in.', |
| 25 |
'enum' => ['elementor', 'gutenberg'], |
| 26 |
], |
| 27 |
'data' => [ |
| 28 |
'type' => 'string', |
| 29 |
'description' => 'Serialized builder data. For Elementor pass the JSON Elementor exporter output. For Gutenberg pass the serialized block markup or block JSON.', |
| 30 |
], |
| 31 |
'post_id' => [ |
| 32 |
'type' => 'string', |
| 33 |
'description' => 'Source post/page ID. Used to capture nxt-* custom meta alongside the saved template.', |
| 34 |
], |
| 35 |
'name' => [ |
| 36 |
'type' => 'string', |
| 37 |
'description' => 'Optional template display name. Defaults to the source post title when omitted.', |
| 38 |
], |
| 39 |
'type' => [ |
| 40 |
'type' => 'string', |
| 41 |
'description' => 'Template type (e.g. "page", "section", "block").', |
| 42 |
], |
| 43 |
'plugins' => [ |
| 44 |
'type' => 'array', |
| 45 |
'description' => 'Optional list of cloud plugin IDs (integers) this template requires — populates the template\'s plugins_id so the library shows its plugin dependencies. Get the IDs from wdesignkit/get-template-plugins.', |
| 46 |
'items' => ['type' => 'integer'], |
| 47 |
], |
| 48 |
], |
| 49 |
'required' => ['builder', 'data'], |
| 50 |
'additionalProperties' => false, |
| 51 |
], |
| 52 |
'output_schema' => [ |
| 53 |
'type' => 'object', |
| 54 |
'properties' => [ |
| 55 |
'success' => ['type' => 'boolean'], |
| 56 |
'message' => ['type' => 'string'], |
| 57 |
'response' => ['type' => ['object', 'array']], |
| 58 |
], |
| 59 |
], |
| 60 |
'execute_callback' => 'wdesignkit_mcp_save_template', |
| 61 |
'permission_callback' => 'wdesignkit_mcp_permission_callback', |
| 62 |
'meta' => [ |
| 63 |
'public' => true, |
| 64 |
'show_in_rest' => true, |
| 65 |
'mcp' => ['public' => true], |
| 66 |
'annotations' => [ |
| 67 |
'instructions' => implode("\n", [ |
| 68 |
'Saves a new cloud template. Requires WDesignKit login.', |
| 69 |
'The "data" field MUST already be plain JSON / serialized markup — do NOT base64 encode it.', |
| 70 |
'If post_id is provided, every post_meta key starting with "nxt-" is merged into the saved payload as custom_meta.', |
| 71 |
'Use wdesignkit/update-template to modify an existing saved template instead of duplicating it here.', |
| 72 |
]), |
| 73 |
'readonly' => false, |
| 74 |
'destructive' => false, |
| 75 |
'idempotent' => false, |
| 76 |
], |
| 77 |
], |
| 78 |
]); |
| 79 |
|
| 80 |
function wdesignkit_mcp_save_template(array $input): array { |
| 81 |
set_time_limit(90); |
| 82 |
|
| 83 |
$auth = wdesignkit_mcp_template_get_auth(); |
| 84 |
if (empty($auth['logged_in'])) { |
| 85 |
return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in.']; |
| 86 |
} |
| 87 |
|
| 88 |
$builder = sanitize_text_field((string) ($input['builder'] ?? '')); |
| 89 |
$data = (string) ($input['data'] ?? ''); |
| 90 |
$post_id = sanitize_text_field((string) ($input['post_id'] ?? '')); |
| 91 |
$name = sanitize_text_field((string) ($input['name'] ?? '')); |
| 92 |
$type = sanitize_text_field((string) ($input['type'] ?? '')); |
| 93 |
|
| 94 |
// Map the caller's friendly type to the literal value the cloud stores and filters on |
| 95 |
// ('section' / 'pagetemplate'). "block" has no confirmed cloud equivalent, so it passes |
| 96 |
// through unmapped rather than being silently rewritten to something wrong. |
| 97 |
$cloud_type_map = ['page' => 'pagetemplate', 'section' => 'section']; |
| 98 |
$cloud_type = $cloud_type_map[$type] ?? $type; |
| 99 |
|
| 100 |
if (!in_array($builder, ['elementor', 'gutenberg'], true)) { |
| 101 |
return ['success' => false, 'message' => 'builder must be "elementor" or "gutenberg".']; |
| 102 |
} |
| 103 |
if ($data === '') { |
| 104 |
return ['success' => false, 'message' => 'data is required — pass the full builder export payload.']; |
| 105 |
} |
| 106 |
|
| 107 |
// Wrap the payload in the envelope the cloud, the importer and the editor JS all expect — |
| 108 |
// the same one the Save Template UI builds in main_save_template.js: |
| 109 |
// { file_type, title, page_id, content, el_type, settings } with the layout in `content`. |
| 110 |
// |
| 111 |
// Sending the raw layout as `data` instead broke both builders, in different ways: |
| 112 |
// Elementor — _elementor_data decodes to a LIST, and adding the string key 'custom_meta' to |
| 113 |
// a list makes wp_json_encode() emit an object, so the indices became "0","1",… |
| 114 |
// Gutenberg — block markup is a STRING, so json_decode() returned null, the is_array() |
| 115 |
// branch was skipped, and the markup was stored bare with no envelope at all. |
| 116 |
// The editor then runs JSON.parse() on it and throws. |
| 117 |
// Building the envelope first fixes both: custom_meta now goes onto an OBJECT, where an extra |
| 118 |
// key cannot reshape `content`. |
| 119 |
// |
| 120 |
// el_type is left empty — the UI derives it from the Elementor preview DOM, which has no |
| 121 |
// server-side equivalent. |
| 122 |
if ('elementor' === $builder) { |
| 123 |
$content = json_decode($data, true); |
| 124 |
if (!is_array($content)) { |
| 125 |
return [ |
| 126 |
'success' => false, |
| 127 |
'message' => 'data is not valid Elementor JSON — pass the _elementor_data export (a JSON array), not markup.', |
| 128 |
]; |
| 129 |
} |
| 130 |
} else { |
| 131 |
// Gutenberg content is serialized block markup; keep the string unless block JSON was sent. |
| 132 |
$decoded = json_decode($data, true); |
| 133 |
$content = is_array($decoded) ? $decoded : $data; |
| 134 |
|
| 135 |
} |
| 136 |
|
| 137 |
$settings = []; |
| 138 |
if ($post_id !== '') { |
| 139 |
$page_settings = get_post_meta((int) $post_id, '_elementor_page_settings', true); |
| 140 |
if (is_array($page_settings) && !empty($page_settings)) { |
| 141 |
$settings = $page_settings; |
| 142 |
} |
| 143 |
} |
| 144 |
|
| 145 |
$envelope = [ |
| 146 |
'file_type' => 'elementor' === $builder ? 'elementor' : 'wp_block', |
| 147 |
'title' => $name, |
| 148 |
'page_id' => $post_id !== '' ? (int) $post_id : 0, |
| 149 |
'content' => $content, |
| 150 |
'el_type' => '', |
| 151 |
'settings' => $settings, |
| 152 |
]; |
| 153 |
|
| 154 |
// The Plus Addons resolves its global button styles, radii and shadows from the kit by |
| 155 |
// `_id`. Without those definitions travelling with the template, an imported section keeps |
| 156 |
// the reference but loses the styling. Scoped to what this layout actually references, so a |
| 157 |
// section carries its own globals rather than a copy of the whole kit. |
| 158 |
$tp_globals = ('elementor' === $builder && is_array($content)) ? wdesignkit_mcp_tp_globals($content) : []; |
| 159 |
if (!empty($tp_globals)) { |
| 160 |
$envelope['tp_globals'] = $tp_globals; |
| 161 |
|
| 162 |
// A Plus global can reference an Elementor global colour or font of its own. Those refs |
| 163 |
// live in the lists rather than the layout, so they need capturing separately or the |
| 164 |
// button style arrives pointing at an id the destination kit does not have. |
| 165 |
$tp_refs = wdesignkit_mcp_tp_global_refs($tp_globals); |
| 166 |
if (!empty($tp_refs['color']) || !empty($tp_refs['typography'])) { |
| 167 |
$envelope['tp_global_refs'] = $tp_refs; |
| 168 |
} |
| 169 |
} |
| 170 |
|
| 171 |
if ($post_id !== '') { |
| 172 |
$custom_fields = []; |
| 173 |
foreach ((array) get_post_custom($post_id) as $key => $value) { |
| 174 |
if (is_string($key) && str_contains($key, 'nxt-')) { |
| 175 |
$custom_fields[$key] = $value; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
if (!empty($custom_fields)) { |
| 180 |
$envelope['custom_meta'] = $custom_fields; |
| 181 |
} |
| 182 |
} |
| 183 |
|
| 184 |
$data = wp_json_encode($envelope); |
| 185 |
|
| 186 |
$args = [ |
| 187 |
'token' => $auth['token'], |
| 188 |
'data' => $data, |
| 189 |
'post_id' => $post_id, |
| 190 |
'builder' => $builder, |
| 191 |
// The cloud SetSaveTemplate endpoint reads the display name from 'title' |
| 192 |
// (request->get('title')) and ignores 'name' — sending only 'name' is why |
| 193 |
// templates saved via this ability came back with a blank title. Send 'title' |
| 194 |
// (keep 'name' too, harmlessly, for any other consumer). |
| 195 |
'title' => $name, |
| 196 |
'name' => $name, |
| 197 |
// The cloud stores the template's type from 'template_type', NOT 'type': |
| 198 |
// $TemplateType = $request->get('template_type'); |
| 199 |
// 'type' => !empty($TemplateType) ? $TemplateType : 'pagetemplate', |
| 200 |
// (WdkitPluginController::SetSaveTemplate lines 171 / 252). Sending only 'type' meant |
| 201 |
// $TemplateType was always empty, so EVERY template saved through this ability was |
| 202 |
// stored as 'pagetemplate' regardless of the caller's input — a section saved here |
| 203 |
// could never be found by list-templates(type: 'section'). Send 'template_type' too. |
| 204 |
// |
| 205 |
// The cloud's own queries match the literal values 'section' and 'pagetemplate' |
| 206 |
// (see the whereIn at SetSaveTemplate line 574), not 'page'/'block', so map first. |
| 207 |
'type' => $type, |
| 208 |
'template_type' => $cloud_type, |
| 209 |
]; |
| 210 |
|
| 211 |
// Optional: record which cloud plugin IDs this template requires. The cloud |
| 212 |
// SetSaveTemplate endpoint reads 'plugins' as a JSON string of integer plugin IDs |
| 213 |
// and stores them as the template's plugins_id (previously always empty because the |
| 214 |
// ability had no way to supply them). |
| 215 |
$plugins_in = $input['plugins'] ?? []; |
| 216 |
if (is_string($plugins_in)) { |
| 217 |
$decoded = json_decode($plugins_in, true); |
| 218 |
$plugins_in = is_array($decoded) ? $decoded : []; |
| 219 |
} |
| 220 |
if (is_array($plugins_in)) { |
| 221 |
$plugin_ids = []; |
| 222 |
foreach ($plugins_in as $pid) { |
| 223 |
$pid = (int) $pid; |
| 224 |
if ($pid > 0) { |
| 225 |
$plugin_ids[] = $pid; |
| 226 |
} |
| 227 |
} |
| 228 |
if (!empty($plugin_ids)) { |
| 229 |
// Sent as a JSON string: the cloud json_decodes 'plugins' server-side. |
| 230 |
$args['plugins'] = wp_json_encode(array_values(array_unique($plugin_ids))); |
| 231 |
} |
| 232 |
} |
| 233 |
|
| 234 |
$response = wdesignkit_mcp_template_cloud_call('save_template', $args, 'json'); |
| 235 |
|
| 236 |
return [ |
| 237 |
'success' => (bool) ($response['success'] ?? false), |
| 238 |
'message' => $response['message'] ?? '', |
| 239 |
'response' => $response, |
| 240 |
]; |
| 241 |
} |
| 242 |
|