PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / third-party / page-builder / elementor / class-avcf-abilities-elementor.php
atarim-visual-collaboration / third-party / page-builder / elementor Last commit date
class-avcf-abilities-elementor-pro.php 2 weeks ago class-avcf-abilities-elementor.php 1 week ago class-avcf-elementor-detector.php 4 weeks ago class-avcf-elementor-helpers.php 1 week ago
class-avcf-abilities-elementor.php
713 lines
1 <?php
2 /**
3 * Elementor — MCP abilities (orchestrator + core tree editing).
4 *
5 * Agent-driven editing of Elementor's _elementor_data element tree. This is a
6 * separate surface from any human inline-editing layer: it reads the tree,
7 * exposes widget/style control schemas, and mutates the tree (add / edit /
8 * move / delete element, or replace the whole tree) through the shared
9 * AVCF_Elementor_Helpers round-trip, which persists via Elementor's Document
10 * API where possible and regenerates CSS.
11 *
12 * Scope: the stable, load-bearing tree operations. Advanced Pro surfaces
13 * (atomic widgets, global classes, global styles v3, dynamic tags,
14 * interactions, variables) are intentionally out of this first cluster.
15 *
16 * Exposed abilities (atarim/elementor-*):
17 * check-setup, get-content, get-widget-schema, get-style-schema,
18 * add-element, edit-element, move-element, delete-element, set-content
19 *
20 * @package atarim-visual-collaboration
21 */
22
23 if ( ! defined('ABSPATH') ) {
24 exit;
25 }
26
27 class AVCF_Abilities_Elementor extends AVCF_Abilities_Base {
28
29 /** @var AVCF_Elementor_Detector */
30 private $detector;
31
32 public function __construct() {
33 $this->detector = new AVCF_Elementor_Detector();
34 }
35
36 public function register() {
37 if ( ! $this->detector->avcf_elementor_is_available() ) {
38 return;
39 }
40 $this->register_check_setup();
41 $this->register_get_content();
42 $this->register_schemas();
43 $this->register_add_element();
44 $this->register_edit_element();
45 $this->register_move_element();
46 $this->register_delete_element();
47 $this->register_set_content();
48 $this->register_clear_cache();
49 $this->register_import_template();
50 }
51
52 /* ----------------------------- helpers ----------------------------- */
53
54 private function can_edit( $post_id ) {
55 return current_user_can( 'edit_post', (int) $post_id ) || current_user_can( 'edit_posts' );
56 }
57 private function ro_meta() {
58 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ] ];
59 }
60 private function write_meta( $destructive = false ) {
61 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => false, 'destructive' => (bool) $destructive, 'idempotent' => false ] ];
62 }
63
64 /** Validate a post is Elementor-built; returns error array or null. */
65 private function require_elementor_post( $post_id ) {
66 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
67 return [ 'success' => false, 'message' => 'A valid post_id is required.' ];
68 }
69 if ( ! AVCF_Elementor_Helpers::is_elementor_post( $post_id ) ) {
70 return [ 'success' => false, 'message' => sprintf( 'Post %d is not built with Elementor (no builder data). Open it in Elementor once, or use set-content to initialise it.', $post_id ) ];
71 }
72 return null;
73 }
74
75 /** Shape an Elementor control for schema output. */
76 private function shape_control( $name, $control ) {
77 $c = is_array( $control ) ? $control : [];
78 $out = [
79 'name' => (string) $name,
80 'type' => isset( $c['type'] ) ? (string) $c['type'] : '',
81 'label' => isset( $c['label'] ) ? (string) $c['label'] : '',
82 'tab' => isset( $c['tab'] ) ? (string) $c['tab'] : '',
83 ];
84 if ( isset( $c['default'] ) ) { $out['default'] = $c['default']; }
85 if ( isset( $c['options'] ) ) { $out['options'] = $c['options']; }
86 if ( isset( $c['description'] ) ){ $out['description'] = (string) $c['description']; }
87 return $out;
88 }
89
90 /* --------------------------- check-setup --------------------------- */
91
92 private function register_check_setup() {
93 $detector = $this->detector;
94 wp_register_ability( 'atarim/elementor-check-setup', [
95 'label' => 'Check Elementor Setup',
96 'description' => 'Call first before other Elementor abilities. Reports whether Elementor is active, its version, and whether Elementor Pro is present.',
97 'category' => 'atarim',
98 'input_schema' => [ 'type' => 'object', 'properties' => [], 'additionalProperties' => false ],
99 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'active' => [ 'type' => 'boolean' ], 'version' => [ 'type' => 'string' ], 'pro' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'active', 'message' ] ],
100 'execute_callback' => function( $input = [] ) use ( $detector ) {
101 return [ 'success' => true, 'active' => $detector->avcf_elementor_is_available(), 'version' => $detector->avcf_elementor_version(), 'pro' => $detector->avcf_elementor_is_pro(), 'message' => 'OK.' ];
102 },
103 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
104 'meta' => $this->ro_meta(),
105 ] );
106 }
107
108 /* --------------------------- clear-cache --------------------------- */
109
110 private function register_clear_cache() {
111 wp_register_ability( 'atarim/elementor-clear-cache', [
112 'label' => 'Clear Elementor Cache',
113 'description' => 'Clear Elementor\'s cached render (element cache + page assets) and generated CSS for a post, and by default regenerate that post\'s CSS immediately so the front end reflects recent edits. Omit post_id to clear ALL Elementor cache site-wide (regenerates lazily on next view). Use this after writing Elementor data through a path that does not auto-clear (e.g. a raw _elementor_data meta write); Elementor caches rendered output separately, so without this the old HTML/CSS keeps being served and a successful edit looks like it did nothing. When regenerating per-post, it also returns the produced CSS size, status and hash (and the CSS itself if return_css is set) so you can validate what was generated — e.g. detect CSS that came out empty or truncated.',
114 'category' => 'atarim',
115 'input_schema' => [
116 'type' => 'object',
117 'properties' => [
118 'post_id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'Post to clear + regenerate. Omit to clear all Elementor cache site-wide.' ],
119 'regenerate' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Rebuild the post CSS immediately (per-post only). Default true.' ],
120 'return_css' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Also return the regenerated CSS content (per-post only) so you can inspect/validate it — e.g. confirm styles for later sections are present, not truncated. Off by default to keep the response small; the size/status/hash are always returned regardless.' ],
121 ],
122 'additionalProperties' => false,
123 ],
124 'output_schema'=> [
125 'type' => 'object',
126 'properties' => [
127 'success' => [ 'type' => 'boolean' ],
128 'scope' => [ 'type' => 'string' ],
129 'post_id' => [ 'type' => 'integer' ],
130 'regenerated' => [ 'type' => 'boolean' ],
131 'css_bytes' => [ 'type' => 'integer' ],
132 'css_sha1' => [ 'type' => 'string' ],
133 'css_status' => [ 'type' => 'string' ],
134 'css_empty' => [ 'type' => 'boolean' ],
135 'css' => [ 'type' => 'string' ],
136 'message' => [ 'type' => 'string' ],
137 ],
138 'required' => [ 'success', 'message' ],
139 ],
140 'execute_callback' => function( $input = [] ) {
141 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
142 $regenerate = ! array_key_exists( 'regenerate', $input ) || filter_var( $input['regenerate'], FILTER_VALIDATE_BOOLEAN );
143
144 if ( $post_id > 0 ) {
145 if ( ! get_post( $post_id ) ) {
146 return [ 'success' => false, 'scope' => 'post', 'message' => sprintf( 'Post %d not found.', $post_id ) ];
147 }
148 AVCF_Elementor_Helpers::clear_css_cache( $post_id );
149 clean_post_cache( $post_id );
150
151 $return_css = ! empty( $input['return_css'] ) && filter_var( $input['return_css'], FILTER_VALIDATE_BOOLEAN );
152
153 $out = [ 'success' => true, 'scope' => 'post', 'post_id' => $post_id ];
154 if ( ! $regenerate ) {
155 $out['regenerated'] = false;
156 $out['message'] = sprintf( 'Cleared Elementor cache for post %d (CSS not regenerated; it will rebuild on next view).', $post_id );
157 return $out;
158 }
159
160 $report = AVCF_Elementor_Helpers::regenerate_post_css( $post_id );
161 $out['regenerated'] = ! empty( $report['regenerated'] );
162 if ( empty( $report['regenerated'] ) ) {
163 $out['message'] = sprintf( 'Cleared Elementor cache for post %d, but CSS regeneration failed%s.', $post_id, isset( $report['reason'] ) ? ': ' . $report['reason'] : '' );
164 return $out;
165 }
166
167 // Report what was produced so the caller can validate it
168 // (detect empty/truncated CSS, e.g. styles dropping past a
169 // section and falling back to kit defaults).
170 $out['css_bytes'] = (int) $report['css_bytes'];
171 $out['css_sha1'] = (string) $report['css_sha1'];
172 $out['css_status'] = (string) $report['css_status'];
173 $out['css_empty'] = (bool) $report['css_empty'];
174 if ( $return_css ) {
175 $out['css'] = (string) $report['content'];
176 }
177 $msg = sprintf( 'Cleared Elementor cache for post %d and regenerated its CSS (%d bytes, status: %s).', $post_id, (int) $report['css_bytes'], (string) $report['css_status'] );
178 if ( ! empty( $report['css_empty'] ) ) {
179 $msg .= ' WARNING: the regenerated CSS is empty. If this post has styled elements, this indicates a generation problem rather than a cache issue (e.g. a host file-write/size limit) — verify on another host or via WP-CLI.';
180 }
181 $out['message'] = $msg;
182 return $out;
183 }
184
185 $ok = AVCF_Elementor_Helpers::purge_all_css();
186 return [
187 'success' => $ok,
188 'scope' => 'site',
189 'regenerated' => false,
190 'message' => $ok
191 ? 'Cleared all Elementor cache site-wide. CSS regenerates on next view.'
192 : 'Could not access the Elementor files manager to clear site-wide cache.',
193 ];
194 },
195 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
196 'meta' => $this->write_meta( false ),
197 ] );
198 }
199
200 /* --------------------------- import-template --------------------------- */
201
202 private function register_import_template() {
203 wp_register_ability( 'atarim/elementor-import-template', [
204 'label' => 'Import Elementor Template',
205 'description' => 'Import an Elementor template into the site template library from its EXPORT JSON (the format produced by Elementor\'s Template > Export, i.e. an object with content/type/title/version — NOT a raw _elementor_data element array). Provide the JSON in content (a string), content_base64 (base64 of the file bytes — use this for a handed-over file, and the only way to pass a binary .zip of multiple templates), or a url to fetch it from. Uses Elementor\'s own importer, so version migration, template type, and page settings are handled. Returns the new template_id(s); insert one into a page afterwards, or read it with elementor-get-content. Does not change any existing page.',
206 'category' => 'atarim',
207 'input_schema' => [ 'type' => 'object', 'properties' => [
208 'content' => [ 'type' => 'string', 'description' => 'The template export JSON (as a string).' ],
209 'content_base64' => [ 'type' => 'string', 'description' => 'Base64 of the file bytes (JSON or a .zip). Use this to import a handed-over/exported file, especially a binary .zip. Used if content is omitted.' ],
210 'url' => [ 'type' => 'string', 'description' => 'URL to fetch the template .json (or .zip) from. Used if content and content_base64 are omitted.' ],
211 'import_mode' => [ 'type' => 'string', 'enum' => [ 'match_site', 'inline' ], 'description' => 'Elementor import mode. Default match_site.' ],
212 ], 'additionalProperties' => false ],
213 'output_schema'=> [ 'type' => 'object', 'properties' => [
214 'success' => [ 'type' => 'boolean' ],
215 'templates' => [ 'type' => 'array' ],
216 'message' => [ 'type' => 'string' ],
217 ], 'required' => [ 'success', 'message' ] ],
218 'execute_callback' => function( $input = [] ) {
219 if ( ! class_exists( '\Elementor\Plugin' ) ) { return [ 'success' => false, 'message' => 'Elementor is not active.' ]; }
220 $tm = \Elementor\Plugin::$instance->templates_manager;
221 if ( ! is_object( $tm ) || ! method_exists( $tm, 'get_source' ) ) { return [ 'success' => false, 'message' => 'Elementor template library is unavailable.' ]; }
222
223 // Resolve the payload: inline content, else base64 bytes, else fetch from url.
224 $content = isset( $input['content'] ) ? (string) $input['content'] : '';
225 $is_zip = false;
226 if ( $content === '' && ! empty( $input['content_base64'] ) ) {
227 $decoded = base64_decode( (string) $input['content_base64'], true );
228 if ( false === $decoded || '' === $decoded ) { return [ 'success' => false, 'message' => 'content_base64 is not valid base64.' ]; }
229 $content = $decoded;
230 // A .zip starts with the "PK" local-file-header magic bytes.
231 $is_zip = ( substr( $content, 0, 2 ) === 'PK' );
232 }
233 if ( $content === '' && ! empty( $input['url'] ) ) {
234 $url = esc_url_raw( (string) $input['url'] );
235 $resp = wp_remote_get( $url, [ 'timeout' => 20 ] );
236 if ( is_wp_error( $resp ) ) { return [ 'success' => false, 'message' => 'Could not fetch url: ' . $resp->get_error_message() ]; }
237 if ( (int) wp_remote_retrieve_response_code( $resp ) !== 200 ) { return [ 'success' => false, 'message' => 'Fetch failed (HTTP ' . (int) wp_remote_retrieve_response_code( $resp ) . ').' ]; }
238 $content = (string) wp_remote_retrieve_body( $resp );
239 $is_zip = ( 'zip' === strtolower( pathinfo( wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ) );
240 }
241 if ( $content === '' ) { return [ 'success' => false, 'message' => 'Provide the template export JSON in content, base64 bytes in content_base64, or a url to fetch it from.' ]; }
242
243 // Sanity-check JSON payloads (skip for zip).
244 if ( ! $is_zip ) {
245 $probe = json_decode( $content, true );
246 if ( ! is_array( $probe ) || ! isset( $probe['content'] ) ) {
247 return [ 'success' => false, 'message' => 'This does not look like an Elementor template export (expected an object with a "content" key). Pass the exported .json, not a raw _elementor_data array.' ];
248 }
249 }
250
251 // Write to a temp file for Elementor's file-based importer.
252 $ext = $is_zip ? 'zip' : 'json';
253 $tmp = wp_tempnam( 'atarim-elementor-import.' . $ext );
254 if ( ! $tmp ) { return [ 'success' => false, 'message' => 'Could not create a temp file for import.' ]; }
255 if ( false === file_put_contents( $tmp, $content ) ) { @unlink( $tmp ); return [ 'success' => false, 'message' => 'Could not write the temp import file.' ]; }
256
257 try {
258 $source = $tm->get_source( 'local' );
259 if ( ! is_object( $source ) ) { @unlink( $tmp ); return [ 'success' => false, 'message' => 'Elementor local template source unavailable.' ]; }
260 $mode = ( isset( $input['import_mode'] ) && in_array( $input['import_mode'], [ 'match_site', 'inline' ], true ) ) ? $input['import_mode'] : 'match_site';
261 $result = $source->import_template( 'atarim-elementor-import.' . $ext, $tmp, $mode );
262 } catch ( \Throwable $e ) {
263 @unlink( $tmp );
264 return [ 'success' => false, 'message' => 'Import failed: ' . $e->getMessage() ];
265 }
266 @unlink( $tmp );
267
268 if ( is_wp_error( $result ) ) {
269 return [ 'success' => false, 'message' => 'Import failed: ' . $result->get_error_message() ];
270 }
271
272 // Normalize: importer returns one item or a list of items.
273 $items = ( isset( $result[0] ) && is_array( $result[0] ) ) ? $result : [ $result ];
274 $templates = [];
275 foreach ( $items as $it ) {
276 if ( ! is_array( $it ) ) { continue; }
277 $templates[] = [
278 'template_id' => isset( $it['template_id'] ) ? (int) $it['template_id'] : ( isset( $it['id'] ) ? (int) $it['id'] : 0 ),
279 'title' => isset( $it['title'] ) ? (string) $it['title'] : '',
280 'type' => isset( $it['type'] ) ? (string) $it['type'] : '',
281 ];
282 }
283 if ( empty( $templates ) ) { return [ 'success' => false, 'message' => 'Import returned no templates.' ]; }
284
285 return [
286 'success' => true,
287 'templates' => $templates,
288 'message' => sprintf( 'Imported %d template(s) into the library: %s.', count( $templates ), implode( ', ', array_map( function( $t ) { return sprintf( '#%d "%s"', $t['template_id'], $t['title'] ); }, $templates ) ) ),
289 ];
290 },
291 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
292 'meta' => $this->write_meta( false ),
293 ] );
294 }
295
296 /* --------------------------- get-content --------------------------- */
297
298 private function register_get_content() {
299 $self = $this;
300 wp_register_ability( 'atarim/elementor-get-content', [
301 'label' => 'Get Elementor Content',
302 'description' => 'Read a post\'s Elementor structure. By default returns a depth-limited structural SUMMARY (each node: id, elType, widgetType, child_count) with NO settings and truncated past depth 6 — ideal for locating the element id you want to edit, but NOT round-trippable (it omits links, text and all widget config, and cuts deep branches). For migration or any read -> recompose -> write, pass include_settings:true (whole tree with settings) or read individual elements by element_id; otherwise you WILL lose settings (e.g. image links) and deep widgets. The summary response sets settings_omitted:true and truncated:true so you can tell it is lossy; use depth to raise the summary depth limit.',
303 'category' => 'atarim',
304 'input_schema' => [ 'type' => 'object', 'properties' => [
305 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
306 'element_id' => [ 'type' => 'string', 'description' => 'Return the full data for just this element (and its subtree).' ],
307 'include_settings' => [ 'type' => 'boolean', 'description' => 'Return the entire raw tree with settings (round-trippable). Defaults to false (lossy structural summary).', 'default' => false ],
308 'depth' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'Max depth for the structural summary (default 6). Ignored when include_settings or element_id is used.' ],
309 ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
310 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'post_id' => [ 'type' => 'integer' ], 'tree' => [ 'type' => 'array' ], 'element' => [ 'type' => 'object' ], 'settings_omitted' => [ 'type' => 'boolean' ], 'truncated' => [ 'type' => 'boolean' ], 'max_depth' => [ 'type' => 'integer' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
311 'execute_callback' => function( $input = [] ) use ( $self ) {
312 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
313 $err = $self->require_elementor_post( $post_id );
314 if ( $err ) { return $err; }
315 $tree = AVCF_Elementor_Helpers::read_tree( $post_id );
316 if ( ! empty( $input['element_id'] ) ) {
317 $el = AVCF_Elementor_Helpers::find( $tree, (string) $input['element_id'] );
318 if ( $el === null ) {
319 return [ 'success' => false, 'message' => sprintf( 'Element "%s" not found on post %d.', $input['element_id'], $post_id ) ];
320 }
321 return [ 'success' => true, 'post_id' => $post_id, 'element' => $el, 'message' => 'OK.' ];
322 }
323 if ( ! empty( $input['include_settings'] ) ) {
324 return [ 'success' => true, 'post_id' => $post_id, 'tree' => $tree, 'settings_omitted' => false, 'truncated' => false, 'message' => 'OK (full tree with settings — round-trippable).' ];
325 }
326 $depth = isset( $input['depth'] ) ? max( 1, (int) $input['depth'] ) : 6;
327 $truncated = false;
328 $summary = AVCF_Elementor_Helpers::summarize( $tree, $depth, 0, $truncated );
329 return [
330 'success' => true,
331 'post_id' => $post_id,
332 'tree' => $summary,
333 'settings_omitted' => true,
334 'truncated' => $truncated,
335 'max_depth' => $depth,
336 'message' => $truncated
337 ? sprintf( 'Structural summary only — settings omitted and tree truncated at depth %d. This is LOSSY: for migration or read->recompose->write use include_settings:true (or read by element_id).', $depth )
338 : 'Structural summary only — settings omitted (links, text and widget config are not included). For migration or read->recompose->write use include_settings:true (or read by element_id).',
339 ];
340 },
341 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
342 'meta' => $this->ro_meta(),
343 ] );
344 }
345
346 /* ---------------------------- schemas ------------------------------ */
347
348 private function register_schemas() {
349 $self = $this;
350
351 wp_register_ability( 'atarim/elementor-get-widget-schema', [
352 'label' => 'Get Elementor Widget Schema',
353 'description' => 'List available Elementor widgets, or get the control schema for one widget. Omit widget to list all registered widgets (name, title). Pass widget (e.g. "heading", "button", "image") to get its controls: name, type, label, tab (content/style/advanced), default, options. Use this to know which settings keys add-element / edit-element accept.',
354 'category' => 'atarim',
355 'input_schema' => [ 'type' => 'object', 'properties' => [ 'widget' => [ 'type' => 'string', 'description' => 'Widget name. Omit to list all widgets.' ] ], 'additionalProperties' => false ],
356 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'widgets' => [ 'type' => 'array' ], 'widget' => [ 'type' => 'string' ], 'controls' => [ 'type' => 'array' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
357 'execute_callback' => function( $input = [] ) use ( $self ) {
358 $plugin = \Elementor\Plugin::$instance;
359 $wm = isset( $plugin->widgets_manager ) ? $plugin->widgets_manager : null;
360 if ( ! is_object( $wm ) ) {
361 return [ 'success' => false, 'message' => 'Elementor widgets manager unavailable.' ];
362 }
363 $name = isset( $input['widget'] ) ? (string) $input['widget'] : '';
364 try {
365 if ( $name === '' ) {
366 $types = $wm->get_widget_types();
367 $list = [];
368 foreach ( (array) $types as $key => $widget ) {
369 $list[] = [
370 'name' => is_object( $widget ) && method_exists( $widget, 'get_name' ) ? $widget->get_name() : (string) $key,
371 'title' => is_object( $widget ) && method_exists( $widget, 'get_title' ) ? $widget->get_title() : (string) $key,
372 ];
373 }
374 return [ 'success' => true, 'widgets' => $list, 'message' => sprintf( '%d widgets.', count( $list ) ) ];
375 }
376 $widget = $wm->get_widget_types( $name );
377 if ( ! $widget ) {
378 return [ 'success' => false, 'message' => sprintf( 'Widget "%s" not found.', $name ) ];
379 }
380 $controls = method_exists( $widget, 'get_controls' ) ? (array) $widget->get_controls() : [];
381 $out = [];
382 foreach ( $controls as $cname => $control ) {
383 $out[] = $self->shape_control( $cname, $control );
384 }
385 return [ 'success' => true, 'widget' => $name, 'controls' => $out, 'message' => 'OK.' ];
386 } catch ( \Throwable $e ) {
387 return [ 'success' => false, 'message' => 'Failed to read widget schema: ' . $e->getMessage() ];
388 }
389 },
390 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
391 'meta' => $this->ro_meta(),
392 ] );
393
394 wp_register_ability( 'atarim/elementor-get-style-schema', [
395 'label' => 'Get Elementor Style Schema',
396 'description' => 'Get the style-tab controls for a widget (typography, colors, spacing, borders, etc.) — the subset of a widget\'s controls whose tab is "style". Use alongside get-widget-schema (which covers content controls) when you need to set a widget\'s appearance.',
397 'category' => 'atarim',
398 'input_schema' => [ 'type' => 'object', 'properties' => [ 'widget' => [ 'type' => 'string', 'description' => 'Widget name (e.g. "heading").' ] ], 'required' => [ 'widget' ], 'additionalProperties' => false ],
399 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'widget' => [ 'type' => 'string' ], 'controls' => [ 'type' => 'array' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
400 'execute_callback' => function( $input = [] ) use ( $self ) {
401 $plugin = \Elementor\Plugin::$instance;
402 $wm = isset( $plugin->widgets_manager ) ? $plugin->widgets_manager : null;
403 if ( ! is_object( $wm ) ) {
404 return [ 'success' => false, 'message' => 'Elementor widgets manager unavailable.' ];
405 }
406 $name = isset( $input['widget'] ) ? (string) $input['widget'] : '';
407 if ( $name === '' ) { return [ 'success' => false, 'message' => 'widget is required.' ]; }
408 try {
409 $widget = $wm->get_widget_types( $name );
410 if ( ! $widget ) { return [ 'success' => false, 'message' => sprintf( 'Widget "%s" not found.', $name ) ]; }
411 $controls = method_exists( $widget, 'get_controls' ) ? (array) $widget->get_controls() : [];
412 $out = [];
413 foreach ( $controls as $cname => $control ) {
414 if ( isset( $control['tab'] ) && $control['tab'] === 'style' ) {
415 $out[] = $self->shape_control( $cname, $control );
416 }
417 }
418 return [ 'success' => true, 'widget' => $name, 'controls' => $out, 'message' => 'OK.' ];
419 } catch ( \Throwable $e ) {
420 return [ 'success' => false, 'message' => 'Failed to read style schema: ' . $e->getMessage() ];
421 }
422 },
423 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
424 'meta' => $this->ro_meta(),
425 ] );
426 }
427
428 /* --------------------------- add-element --------------------------- */
429
430 private function register_add_element() {
431 $self = $this;
432 wp_register_ability( 'atarim/elementor-add-element', [
433 'label' => 'Add Elementor Element',
434 'description' => 'Insert a new element into a post\'s Elementor tree. elType is section, column, container, or widget; when widget, widget_type is required (e.g. "heading"). parent_id is the element to nest under (omit for top level); index is the position among siblings (omit to append). settings is a key/value map matching the widget/element controls (see get-widget-schema). Returns the new element id.',
435 'category' => 'atarim',
436 'input_schema' => [ 'type' => 'object', 'properties' => [
437 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
438 'elType' => [ 'type' => 'string', 'enum' => [ 'section', 'column', 'container', 'widget' ] ],
439 'widget_type' => [ 'type' => 'string', 'description' => 'Required when elType is "widget".' ],
440 'parent_id' => [ 'type' => 'string', 'description' => 'Element to nest under. Omit for top level.' ],
441 'index' => [ 'type' => 'integer', 'description' => 'Position among siblings. Omit to append.', 'minimum' => 0 ],
442 'settings' => [ 'type' => 'object', 'description' => 'Control values for the element.' ],
443 ], 'required' => [ 'post_id', 'elType' ], 'additionalProperties' => false ],
444 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'element_id' => [ 'type' => 'string' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
445 'execute_callback' => function( $input = [] ) use ( $self ) {
446 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
447 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
448 if ( ! $self->can_edit( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
449 $elType = isset( $input['elType'] ) ? (string) $input['elType'] : '';
450 if ( ! in_array( $elType, [ 'section', 'column', 'container', 'widget' ], true ) ) {
451 return [ 'success' => false, 'message' => 'elType must be section, column, container, or widget.' ];
452 }
453 if ( $elType === 'widget' && empty( $input['widget_type'] ) ) {
454 return [ 'success' => false, 'message' => 'widget_type is required when elType is "widget".' ];
455 }
456 $node = [
457 'id' => AVCF_Elementor_Helpers::generate_id(),
458 'elType' => $elType,
459 'settings' => isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : (object) [],
460 'elements' => [],
461 ];
462 if ( $elType === 'widget' ) {
463 $node['widgetType'] = (string) $input['widget_type'];
464 }
465 $tree = AVCF_Elementor_Helpers::read_tree( $post_id );
466 $index = isset( $input['index'] ) ? (int) $input['index'] : null;
467 list( $tree, $inserted ) = AVCF_Elementor_Helpers::insert( $tree, isset( $input['parent_id'] ) ? (string) $input['parent_id'] : null, $node, $index );
468 if ( ! $inserted ) {
469 return [ 'success' => false, 'message' => sprintf( 'parent_id "%s" not found.', isset( $input['parent_id'] ) ? $input['parent_id'] : '' ) ];
470 }
471 if ( ! AVCF_Elementor_Helpers::write_tree( $post_id, $tree ) ) {
472 return [ 'success' => false, 'message' => 'Failed to save the Elementor tree.' ];
473 }
474 return [ 'success' => true, 'element_id' => $node['id'], 'message' => sprintf( 'Added %s element.', $elType === 'widget' ? $node['widgetType'] : $elType ) ];
475 },
476 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
477 'meta' => $this->write_meta( false ),
478 ] );
479 }
480
481 /* --------------------------- edit-element -------------------------- */
482
483 private function register_edit_element() {
484 $self = $this;
485 wp_register_ability( 'atarim/elementor-edit-element', [
486 'label' => 'Edit Elementor Element',
487 'description' => 'Update an element\'s settings and/or styles by id. settings and styles are each shallow-merged into the element\'s existing values (top-level keys you send overwrite, others are kept). Provide at least one of settings or styles — pass styles alone to restyle without touching content. For Elementor v4 atomic elements, styles is the atomic styles object and settings uses the atomic prop shape; read the element first with get-content. The save auto-detects atomic elements and writes them safely (a plain Document save would strip them). For v3 widgets, get control names with get-widget-schema.',
488 'category' => 'atarim',
489 'input_schema' => [ 'type' => 'object', 'properties' => [
490 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
491 'element_id' => [ 'type' => 'string' ],
492 'settings' => [ 'type' => 'object', 'description' => 'Settings to merge into the element. Optional if styles is given.' ],
493 'styles' => [ 'type' => 'object', 'description' => 'Styles to merge into the element (Elementor v4 atomic styles object). Optional if settings is given.' ],
494 ], 'required' => [ 'post_id', 'element_id' ], 'additionalProperties' => false ],
495 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], 'unknown_settings' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
496 'execute_callback' => function( $input = [] ) use ( $self ) {
497 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
498 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
499 if ( ! $self->can_edit( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
500 $element_id = isset( $input['element_id'] ) ? (string) $input['element_id'] : '';
501 if ( $element_id === '' ) { return [ 'success' => false, 'message' => 'element_id is required.' ]; }
502 $settings_patch = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : [];
503 $styles_patch = isset( $input['styles'] ) && is_array( $input['styles'] ) ? $input['styles'] : [];
504 if ( empty( $settings_patch ) && empty( $styles_patch ) ) {
505 return [ 'success' => false, 'message' => 'Provide a non-empty settings and/or styles object.' ];
506 }
507 $tree = AVCF_Elementor_Helpers::read_tree( $post_id );
508 $found = false;
509 $node_type = '';
510 $tree = AVCF_Elementor_Helpers::map_edit( $tree, $element_id, function( $node ) use ( $settings_patch, $styles_patch, &$node_type ) {
511 $node_type = ( isset( $node['widgetType'] ) && $node['widgetType'] !== '' ) ? (string) $node['widgetType'] : ( isset( $node['elType'] ) ? (string) $node['elType'] : '' );
512 if ( ! empty( $settings_patch ) ) {
513 $current = ( isset( $node['settings'] ) && is_array( $node['settings'] ) ) ? $node['settings'] : [];
514 $node['settings'] = array_merge( $current, $settings_patch );
515 }
516 if ( ! empty( $styles_patch ) ) {
517 $current_styles = ( isset( $node['styles'] ) && is_array( $node['styles'] ) ) ? $node['styles'] : [];
518 $node['styles'] = array_merge( $current_styles, $styles_patch );
519 }
520 return $node;
521 }, $found );
522 if ( ! $found ) {
523 return [ 'success' => false, 'message' => sprintf( 'Element "%s" not found.', $element_id ) ];
524 }
525 if ( ! AVCF_Elementor_Helpers::write_tree( $post_id, $tree ) ) {
526 return [ 'success' => false, 'message' => 'Failed to save the Elementor tree.' ];
527 }
528 $updated = [];
529 if ( ! empty( $settings_patch ) ) { $updated[] = 'settings'; }
530 if ( ! empty( $styles_patch ) ) { $updated[] = 'styles'; }
531
532 // Tier-2 validation (non-blocking): flag settings keys the element
533 // type does not define — those are silently ignored on render, so
534 // surfacing them turns a silent no-op into a visible warning.
535 $result = [ 'success' => true, 'updated' => $updated ];
536 $message = sprintf( 'Updated element "%s" (%s).', $element_id, implode( ' + ', $updated ) );
537 if ( ! empty( $settings_patch ) && $node_type !== '' ) {
538 $valid = AVCF_Elementor_Helpers::valid_setting_keys( $node_type );
539 if ( 'unknown' !== $valid['mode'] && ! empty( $valid['keys'] ) ) {
540 $unknown = array_values( array_filter(
541 array_diff( array_keys( $settings_patch ), $valid['keys'] ),
542 function( $k ) { return '__dynamic__' !== $k; }
543 ) );
544 if ( ! empty( $unknown ) ) {
545 $result['unknown_settings'] = $unknown;
546 $message .= sprintf( ' WARNING: %d settings key(s) are not defined by "%s" and are likely ignored: %s. Check names with %s.', count( $unknown ), $node_type, implode( ', ', $unknown ), ( 'atomic' === $valid['mode'] ? 'elementor-get-atomic-schema' : 'elementor-get-widget-schema' ) );
547 }
548 }
549 }
550 $result['message'] = $message;
551 return $result;
552 },
553 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
554 'meta' => $this->write_meta( false ),
555 ] );
556 }
557
558 /* --------------------------- move-element -------------------------- */
559
560 private function register_move_element() {
561 $self = $this;
562 wp_register_ability( 'atarim/elementor-move-element', [
563 'label' => 'Move Elementor Element',
564 'description' => 'Relocate an element (and its subtree) to a new parent and/or position. new_parent_id omitted moves it to the top level; index sets the position among the destination\'s children (omit to append).',
565 'category' => 'atarim',
566 'input_schema' => [ 'type' => 'object', 'properties' => [
567 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
568 'element_id' => [ 'type' => 'string' ],
569 'new_parent_id' => [ 'type' => 'string', 'description' => 'Destination parent. Omit for top level.' ],
570 'index' => [ 'type' => 'integer', 'minimum' => 0 ],
571 ], 'required' => [ 'post_id', 'element_id' ], 'additionalProperties' => false ],
572 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
573 'execute_callback' => function( $input = [] ) use ( $self ) {
574 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
575 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
576 if ( ! $self->can_edit( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
577 $element_id = isset( $input['element_id'] ) ? (string) $input['element_id'] : '';
578 if ( $element_id === '' ) { return [ 'success' => false, 'message' => 'element_id is required.' ]; }
579 $new_parent = isset( $input['new_parent_id'] ) ? (string) $input['new_parent_id'] : null;
580 if ( $new_parent !== null && $new_parent === $element_id ) {
581 return [ 'success' => false, 'message' => 'An element cannot be moved into itself.' ];
582 }
583 $tree = AVCF_Elementor_Helpers::read_tree( $post_id );
584 list( $tree, $removed ) = AVCF_Elementor_Helpers::remove( $tree, $element_id );
585 if ( $removed === null ) {
586 return [ 'success' => false, 'message' => sprintf( 'Element "%s" not found.', $element_id ) ];
587 }
588 $index = isset( $input['index'] ) ? (int) $input['index'] : null;
589 list( $tree, $inserted ) = AVCF_Elementor_Helpers::insert( $tree, $new_parent, $removed, $index );
590 if ( ! $inserted ) {
591 return [ 'success' => false, 'message' => sprintf( 'new_parent_id "%s" not found (element was not moved).', $new_parent ) ];
592 }
593 if ( ! AVCF_Elementor_Helpers::write_tree( $post_id, $tree ) ) {
594 return [ 'success' => false, 'message' => 'Failed to save the Elementor tree.' ];
595 }
596 return [ 'success' => true, 'message' => sprintf( 'Moved element "%s".', $element_id ) ];
597 },
598 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
599 'meta' => $this->write_meta( false ),
600 ] );
601 }
602
603 /* -------------------------- delete-element ------------------------- */
604
605 private function register_delete_element() {
606 $self = $this;
607 wp_register_ability( 'atarim/elementor-delete-element', [
608 'label' => 'Delete Elementor Element',
609 'description' => 'Remove an element (and everything nested inside it) from a post\'s Elementor tree by id. Dry run unless confirm:true.',
610 'category' => 'atarim',
611 'input_schema' => [ 'type' => 'object', 'properties' => [
612 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
613 'element_id' => [ 'type' => 'string' ],
614 'confirm' => [ 'type' => 'boolean', 'default' => false ],
615 ], 'required' => [ 'post_id', 'element_id' ], 'additionalProperties' => false ],
616 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'deleted' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
617 'execute_callback' => function( $input = [] ) use ( $self ) {
618 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
619 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
620 if ( ! $self->can_edit( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
621 $element_id = isset( $input['element_id'] ) ? (string) $input['element_id'] : '';
622 if ( $element_id === '' ) { return [ 'success' => false, 'message' => 'element_id is required.' ]; }
623 $tree = AVCF_Elementor_Helpers::read_tree( $post_id );
624 if ( AVCF_Elementor_Helpers::find( $tree, $element_id ) === null ) {
625 return [ 'success' => false, 'message' => sprintf( 'Element "%s" not found.', $element_id ) ];
626 }
627 if ( empty( $input['confirm'] ) ) {
628 return [ 'success' => true, 'deleted' => false, 'message' => sprintf( 'Dry run: would delete element "%s" and its children. Re-call with confirm:true.', $element_id ) ];
629 }
630 list( $tree, $removed ) = AVCF_Elementor_Helpers::remove( $tree, $element_id );
631 if ( $removed === null ) {
632 return [ 'success' => false, 'message' => sprintf( 'Element "%s" not found.', $element_id ) ];
633 }
634 if ( ! AVCF_Elementor_Helpers::write_tree( $post_id, $tree ) ) {
635 return [ 'success' => false, 'message' => 'Failed to save the Elementor tree.' ];
636 }
637 return [ 'success' => true, 'deleted' => true, 'message' => sprintf( 'Deleted element "%s".', $element_id ) ];
638 },
639 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
640 'meta' => $this->write_meta( true ),
641 ] );
642 }
643
644 /* --------------------------- set-content --------------------------- */
645
646 private function register_set_content() {
647 $self = $this;
648 wp_register_ability( 'atarim/elementor-set-content', [
649 'label' => 'Set Elementor Content',
650 'description' => 'Replace a post\'s ENTIRE Elementor tree with the supplied elements array (the same shape get-content returns with include_settings:true). This is a full overwrite — use it to initialise an Elementor page or rebuild it wholesale; for targeted changes prefer add/edit/move/delete-element. Element ids are preserved as given (or generated for nodes missing one).',
651 'category' => 'atarim',
652 'input_schema' => [ 'type' => 'object', 'properties' => [
653 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
654 'elements' => [ 'type' => 'array', 'description' => 'Full element tree to store.' ],
655 ], 'required' => [ 'post_id', 'elements' ], 'additionalProperties' => false ],
656 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'post_id' => [ 'type' => 'integer' ], 'nodes_written' => [ 'type' => 'integer' ], 'nodes_saved' => [ 'type' => 'integer' ], 'verified' => [ 'type' => 'boolean' ], 'content_hash' => [ 'type' => 'string' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
657 'execute_callback' => function( $input = [] ) use ( $self ) {
658 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
659 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
660 if ( ! $self->can_edit( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
661 if ( ! isset( $input['elements'] ) || ! is_array( $input['elements'] ) ) {
662 return [ 'success' => false, 'message' => 'elements must be an array.' ];
663 }
664 $tree = $self->ensure_ids( $input['elements'] );
665 if ( ! AVCF_Elementor_Helpers::write_tree( $post_id, $tree ) ) {
666 return [ 'success' => false, 'message' => 'Failed to save the Elementor tree.' ];
667 }
668 // Read-back receipt: re-read what was stored and compare node counts,
669 // so a lossy save (e.g. elements dropped) is detectable instead of
670 // reported as a bare success — matching the plugin's other write clusters.
671 $saved = AVCF_Elementor_Helpers::read_tree( $post_id );
672 $nodes_written = AVCF_Elementor_Helpers::count_nodes( $tree );
673 $nodes_saved = AVCF_Elementor_Helpers::count_nodes( $saved );
674 $raw = get_post_meta( $post_id, '_elementor_data', true );
675 $content_hash = sha1( is_string( $raw ) ? $raw : (string) wp_json_encode( $raw ) );
676 $verified = ( $nodes_saved === $nodes_written );
677 return [
678 'success' => true,
679 'post_id' => $post_id,
680 'nodes_written' => $nodes_written,
681 'nodes_saved' => $nodes_saved,
682 'verified' => $verified,
683 'content_hash' => $content_hash,
684 'message' => $verified
685 ? sprintf( 'Replaced Elementor content on post %d (%d nodes, verified against read-back).', $post_id, $nodes_saved )
686 : sprintf( 'Replaced Elementor content on post %d, but the saved node count (%d) differs from what was sent (%d) — elements may have been dropped on save (e.g. atomic stripping). Re-read with include_settings:true to check.', $post_id, $nodes_saved, $nodes_written ),
687 ];
688 },
689 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
690 'meta' => $this->write_meta( true ),
691 ] );
692 }
693
694 /** Recursively ensure every node has an id and an elements array. */
695 private function ensure_ids( $elements ) {
696 $out = [];
697 foreach ( (array) $elements as $el ) {
698 if ( ! is_array( $el ) ) {
699 continue;
700 }
701 if ( empty( $el['id'] ) ) {
702 $el['id'] = AVCF_Elementor_Helpers::generate_id();
703 }
704 if ( ! isset( $el['elements'] ) || ! is_array( $el['elements'] ) ) {
705 $el['elements'] = [];
706 } else {
707 $el['elements'] = $this->ensure_ids( $el['elements'] );
708 }
709 $out[] = $el;
710 }
711 return $out;
712 }
713 }