atarim-visual-collaboration
/
third-party
/
page-builder
/
elementor
/
class-avcf-abilities-elementor.php
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 | } |