'Advanced Addons Animation', 'description' => 'Configure Advanced Addons Motion animations in Elementor.', ) ); } public static function animation_schema(): array { $path = __DIR__ . '/elementor-animation-schema.json'; if ( ! is_readable( $path ) ) { error_log( 'Advanced Addons: Elementor animation schema is missing or unreadable.' ); return array(); } // Suppress filesystem warnings if the file disappears during an update. $json = @file_get_contents( $path ); $schema = false === $json ? null : json_decode( $json, true ); if ( ! is_array( $schema ) || 'object' !== ( $schema['type'] ?? null ) || ! isset( $schema['properties']['element_ids'] ) || ! is_array( $schema['required'] ?? null ) ) { error_log( 'Advanced Addons: Elementor animation schema is invalid.' ); return array(); } return $schema; } public function register_abilities(): void { $schema = self::animation_schema(); if ( empty( $schema ) ) { return; } $schema['properties']['post_id'] = array( 'type' => 'integer', 'minimum' => 1 ); $schema['properties']['request_id'] = array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 128, 'description' => 'Stable caller ID for this operation. Reuse it on retries; never retry the same operation with a new ID.', ); $schema['required'][] = 'request_id'; Compat::register_ability( 'advanced-addons/set-elementor-animation', array( 'label' => 'Set Elementor Animation', 'description' => 'Stage Advanced Addons Motion animation settings for exact Elementor IDs in the fresh selected subtree. Supports Atomic/V4 and legacy elements, presets, custom start/end keyframes, stagger, and scroll-linked settings. Select a common ancestor to address its descendants. Requires an open editor with MCP live editing enabled. The editor verifies and applies settings using history and rollback; staged success does not mean applied or saved. Save in Elementor after verifying the selected element data. Reuse request_id on retries; a pending delivery is never overwritten.', 'category' => 'advanced-animation', 'input_schema' => $schema, 'output_schema' => array( 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), 'staged' => array( 'type' => 'boolean' ), 'code' => array( 'type' => 'string' ), 'message' => array( 'type' => 'string' ), 'post_id' => array( 'type' => 'integer' ), 'result_id' => array( 'type' => 'string' ), 'element_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), ), ), 'execute_callback' => array( $this, 'execute' ), 'permission_callback' => function ( $input ) { $selection = SelectionBridge::read_selection( 'elementor' ); $post_id = intval( $input['post_id'] ?? ( $selection['post_id'] ?? 0 ) ); return $post_id > 0 && Security::can_edit_post( $post_id ); }, 'meta' => array( 'mcp' => array( 'public' => true ), 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ), ), ) ); } private function error( string $code, string $message ): array { return array( 'success' => false, 'staged' => false, 'code' => $code, 'message' => $message ); } private function index_elements( array $element, array &$elements ): void { if ( ! empty( $element['id'] ) ) { $elements[ $element['id'] ] = $element; } foreach ( $element['elements'] ?? array() as $child ) { $this->index_elements( $child, $elements ); } } public function execute( array $input ): array { // Also validate direct calls, not only Abilities API executions. $schema = self::animation_schema(); if ( empty( $schema ) ) { return $this->error( 'animation_schema_unavailable', 'The Elementor animation schema is unavailable. Reinstall Advanced Addons to restore it.' ); } $schema['properties']['post_id'] = array( 'type' => 'integer', 'minimum' => 1 ); $schema['properties']['request_id'] = array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 128 ); $schema['required'][] = 'request_id'; $valid = rest_validate_value_from_schema( $input, $schema, 'animation' ); if ( is_wp_error( $valid ) ) { return $this->error( 'invalid_animation', $valid->get_error_message() ); } $animation = $input; unset( $animation['post_id'], $animation['request_id'] ); if ( isset( $animation['preset'], $animation['keyframes'] ) || ( false !== ( $animation['enabled'] ?? true ) && ! isset( $animation['preset'] ) && empty( $animation['keyframes'] ) ) ) { return $this->error( 'invalid_animation', 'Enabled animations require a preset or keyframes, never both.' ); } foreach ( array_keys( $animation['keyframes'] ?? array() ) as $property ) { if ( ! preg_match( '/^[A-Za-z][A-Za-z0-9]*$/', $property ) || in_array( $property, array( '__proto__', 'prototype', 'constructor' ), true ) ) { return $this->error( 'invalid_animation', 'Invalid Motion keyframe property.' ); } } $selection = SelectionBridge::read_selection( 'elementor' ); $post_id = intval( $input['post_id'] ?? ( $selection['post_id'] ?? 0 ) ); if ( ! $post_id || ! Security::can_edit_post( $post_id ) ) { return $this->error( 'cannot_edit_post', 'You cannot edit this Elementor document.' ); } $key = 'aa_elementor_animation_' . hash( 'sha256', get_current_user_id() . ':' . $input['request_id'] ); $fingerprint = hash( 'sha256', wp_json_encode( array( $post_id, $animation ) ) ); $previous = get_transient( $key ); $pending = SelectionBridge::read_result( 'elementor', $post_id ); if ( $previous ) { if ( $previous['fingerprint'] !== $fingerprint ) { return $this->error( 'request_id_conflict', 'This request_id was already used with different animation arguments.' ); } $response = $previous['response']; $response['staged'] = ( $pending['result_id'] ?? '' ) === $response['result_id']; $response['message'] = $response['staged'] ? 'The same animation delivery is still pending.' : 'This request was already delivered. Inspect the editor; it will not be staged again.'; return $response; } if ( $pending ) { return $this->error( 'delivery_pending', 'Another Elementor delivery is pending. Apply or discard it before staging animations.' ); } if ( ! $selection || intval( $selection['post_id'] ?? 0 ) !== $post_id || ! SelectionBridge::is_fresh( $selection ) ) { return $this->error( 'selection_stale', 'Refresh the Elementor selection for this document before staging animations.' ); } $elements = array(); $this->index_elements( $selection['elementData'] ?? array(), $elements ); $expected = array(); foreach ( $animation['element_ids'] as $id ) { if ( ! isset( $elements[ $id ] ) ) { return $this->error( 'element_not_found', 'Element ' . $id . ' is not in the fresh selected subtree.' ); } $config = array_filter( $elements[ $id ]['settings'] ?? array(), function ( $name ) { return 0 === strpos( $name, 'advanced_animation_' ); }, ARRAY_FILTER_USE_KEY ); $expected[ $id ] = (object) $config; } $result_id = wp_generate_uuid4(); if ( ! SelectionBridge::store_result( array( 'editor' => 'elementor', 'action' => 'elementor_animation', 'post_id' => $post_id, 'result_id' => $result_id, 'animation' => $animation, 'expected_animation' => $expected, 'source_tool' => 'set_elementor_animation', ) ) ) { return $this->error( 'delivery_failed', 'Could not stage the animation delivery.' ); } $response = array( 'success' => true, 'staged' => true, 'post_id' => $post_id, 'result_id' => $result_id, 'element_ids' => $animation['element_ids'], 'message' => 'Animation settings staged. Verify them in the open Elementor editor, then save.', ); set_transient( $key, array( 'fingerprint' => $fingerprint, 'response' => $response ), SelectionBridge::RESULT_TTL ); return $response; } }