register( 'elementor.page.get_tree', [ 'class' => 'read', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'description' => 'Get Elementor page structure as parsed JSON tree.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_get_tree' ], ] ); $this->register( 'elementor.page.get_texts', [ 'class' => 'read', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'description' => 'Extract all text fields (title, editor, button_text, etc.) from Elementor widgets.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_get_texts' ], ] ); $this->register( 'elementor.widget.update_field', [ 'class' => 'write', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'supports_dry_run' => true, 'description' => 'Update a specific field on a specific widget by widget_id.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'widget_id' => [ 'type' => 'string', 'required' => true, 'description' => 'Widget ID from get_tree or get_texts.' ], 'field' => [ 'type' => 'string', 'required' => true, 'description' => 'Field name (e.g. "title", "editor", "button_text").' ], 'value' => [ 'description' => 'New value for the field.' ], ], 'handler' => [ $this, 'handle_update_field' ], ] ); $this->register( 'elementor.page.bulk_update_texts', [ 'class' => 'write', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'supports_dry_run' => true, 'description' => 'Batch-update multiple text fields across the page in one call.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'updates' => [ 'type' => 'array', 'required' => true, 'description' => 'Array of {widget_id, field, text} objects. Use "text" key (matches get_texts output); "value" also accepted.' ], ], 'handler' => [ $this, 'handle_bulk_update_texts' ], ] ); $this->register( 'elementor.page.backup', [ 'class' => 'write', 'required_scopes' => [ 'manage.elementor', 'manage.backups' ], 'dependency' => $dep, 'description' => 'Backup _elementor_data for a post to the backups table.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_backup' ], ] ); $this->register( 'elementor.page.restore', [ 'class' => 'destructive', 'required_scopes' => [ 'manage.elementor', 'manage.backups' ], 'dependency' => $dep, 'requires_confirm' => true, 'description' => 'Restore _elementor_data from a backup. Use target_post_id to copy structure to a different page.', 'input_schema' => [ 'backup_id' => [ 'type' => 'integer', 'required' => true, 'description' => 'Backup ID from elementor.page.backup.' ], 'target_post_id' => [ 'type' => 'integer', 'description' => 'Optional: restore to a different post (for copying/translating). Defaults to original post.' ], 'confirm' => [ 'type' => 'boolean', 'required' => true, 'description' => 'Must be true to execute.' ], ], 'handler' => [ $this, 'handle_restore' ], ] ); $this->register( 'elementor.page.validate', [ 'class' => 'read', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'description' => 'Validate Elementor JSON structure for a post. Returns element_count.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_validate' ], ] ); $this->register( 'elementor.page.regenerate_assets', [ 'class' => 'admin_sensitive', 'required_scopes' => [ 'manage.elementor' ], 'dependency' => $dep, 'description' => 'Regenerate Elementor CSS/assets for a post (clears cached CSS).', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_regenerate_assets' ], ] ); } // ── Handlers ────────────────────────────────────────────────────────── public function handle_get_tree( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } $data = $this->get_elementor_data( $post_id ); if ( $data === null ) { return $this->err( 'NOT_FOUND', "No Elementor data for post $post_id", 'error', 404 ); } return $this->ok( [ 'post_id' => $post_id, 'tree' => $data ], [ 'target_type' => 'elementor_page', 'target_id' => $post_id ] ); } public function handle_get_texts( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } $data = $this->get_elementor_data( $post_id ); if ( $data === null ) { return $this->err( 'NOT_FOUND', "No Elementor data for post $post_id", 'error', 404 ); } $texts = []; $this->extract_texts( $data, $texts ); return $this->ok( [ 'post_id' => $post_id, 'texts' => $texts, 'count' => count( $texts ) ] ); } public function handle_update_field( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $widget_id = $this->require_string( $args, 'widget_id' ); $field = $this->require_string( $args, 'field' ); $value = $args['value'] ?? null; if ( ! $post_id || ! $widget_id || ! $field ) { return $this->err( 'MISSING_PARAM', 'Parameters post_id, widget_id, and field are required', 'validation_failed' ); } $data = $this->get_elementor_data( $post_id ); if ( $data === null ) { return $this->err( 'NOT_FOUND', "No Elementor data for post $post_id", 'error', 404 ); } $updated = false; $this->update_widget_field( $data, $widget_id, $field, $value, $updated ); if ( ! $updated ) { return $this->err( 'NOT_FOUND', "Widget $widget_id not found in page tree", 'error', 404 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_update' => [ 'widget_id' => $widget_id, 'field' => $field ] ] ); } $saved = $this->save_elementor_data( $post_id, $data ); if ( ! $saved ) { return $this->err( 'ENCODE_ERROR', "Failed to JSON-encode Elementor data for post $post_id — data not saved", 'error' ); } return $this->ok( [ 'post_id' => $post_id, 'widget_id' => $widget_id, 'field' => $field, 'updated' => true ], [ 'target_type' => 'elementor_widget', 'target_id' => $post_id ] ); } public function handle_bulk_update_texts( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $updates = (array) ( $args['updates'] ?? [] ); // [['widget_id'=>..,'field'=>..,'value'=>..]] if ( ! $post_id || empty( $updates ) ) { return $this->err( 'MISSING_PARAM', 'Parameters post_id and updates[] are required', 'validation_failed' ); } $data = $this->get_elementor_data( $post_id ); if ( $data === null ) { return $this->err( 'NOT_FOUND', "No Elementor data for post $post_id", 'error', 404 ); } $results = []; foreach ( $updates as $update ) { $widget_id = (string) ( $update['widget_id'] ?? '' ); $field = (string) ( $update['field'] ?? '' ); // Accept both 'text' (matches get_texts output) and 'value' as key names. $value = $update['text'] ?? $update['value'] ?? null; if ( ! $widget_id || ! $field ) { $results[] = [ 'widget_id' => $widget_id, 'success' => false, 'reason' => 'missing widget_id or field' ]; continue; } $updated = false; $this->update_widget_field( $data, $widget_id, $field, $value, $updated ); $results[] = [ 'widget_id' => $widget_id, 'field' => $field, 'success' => $updated ]; } if ( ! $dry_run ) { $saved = $this->save_elementor_data( $post_id, $data ); if ( ! $saved ) { return $this->err( 'ENCODE_ERROR', "Failed to JSON-encode Elementor data for post $post_id — data not saved", 'error' ); } } return $this->ok( [ 'post_id' => $post_id, 'dry_run' => $dry_run, 'results' => $results ], [ 'target_type' => 'elementor_page', 'target_id' => $post_id, 'summary' => [ 'updates' => count( $updates ) ] ] ); } public function handle_backup( array $args, array $key_record, bool $dry_run ): array { global $wpdb; $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } $raw = get_post_meta( $post_id, '_elementor_data', true ); if ( ! $raw ) { return $this->err( 'NOT_FOUND', "No Elementor data for post $post_id", 'error', 404 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_backup_post_id' => $post_id ] ); } $wpdb->insert( $wpdb->prefix . 'aicom_backups', [ 'created_at' => current_time( 'mysql', true ), 'api_key_id' => (int) $key_record['id'], 'tool_name' => 'elementor.page.backup', 'target_type' => 'elementor_page', 'target_id' => (string) $post_id, 'manifest_json'=> wp_json_encode( [ 'post_id' => $post_id, 'meta_key' => '_elementor_data' ] ), 'payload_json' => $raw, ] ); $backup_id = (int) $wpdb->insert_id; return $this->ok( [ 'backup_id' => $backup_id, 'post_id' => $post_id ], [ 'target_type' => 'elementor_page', 'target_id' => $post_id ] ); } public function handle_restore( array $args, array $key_record, bool $dry_run ): array { global $wpdb; $backup_id = $this->require_int( $args, 'backup_id' ); if ( ! $backup_id ) { return $this->err( 'MISSING_PARAM', 'Parameter backup_id is required', 'validation_failed' ); } $backup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aicom_backups WHERE id = %d AND target_type = 'elementor_page'", $backup_id ), ARRAY_A ); if ( ! $backup ) { return $this->err( 'NOT_FOUND', "Backup $backup_id not found", 'error', 404 ); } // Optional: restore to a different post (e.g. copy structure to a translation page) $target_post_id = isset( $args['target_post_id'] ) ? (int) $args['target_post_id'] : (int) $backup['target_id']; if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_restore_backup_id' => $backup_id, 'target_post_id' => $target_post_id ] ); } // wp_slash() counteracts update_post_meta's internal wp_unslash(), // preserving backslash sequences (e.g. escaped HTML attribute quotes in JSON). update_post_meta( $target_post_id, '_elementor_data', wp_slash( $backup['payload_json'] ) ); update_post_meta( $target_post_id, '_elementor_edit_mode', 'builder' ); return $this->ok( [ 'backup_id' => $backup_id, 'post_id' => $target_post_id, 'restored' => true ], [ 'target_type' => 'elementor_page', 'target_id' => $target_post_id ] ); } public function handle_validate( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } $raw = get_post_meta( $post_id, '_elementor_data', true ); if ( ! $raw ) { return $this->ok( [ 'post_id' => $post_id, 'valid' => false, 'reason' => 'No _elementor_data found' ] ); } $data = json_decode( $raw, true ); $valid = json_last_error() === JSON_ERROR_NONE && is_array( $data ); return $this->ok( [ 'post_id' => $post_id, 'valid' => $valid, 'element_count' => $valid ? count( $data ) : 0 ] ); } public function handle_regenerate_assets( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_regenerate_for' => $post_id ] ); } // Clear all Elementor caches for this post delete_post_meta( $post_id, '_elementor_css' ); delete_post_meta( $post_id, '_elementor_element_cache' ); // Elementor Pro HTML cache delete_post_meta( $post_id, '_elementor_page_assets' ); // If Elementor Pro is available, use its API to clear global file cache if ( class_exists( '\Elementor\Plugin' ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } return $this->ok( [ 'post_id' => $post_id, 'regenerated' => true ], [ 'target_type' => 'elementor_page', 'target_id' => $post_id ] ); } // ── Private Helpers ─────────────────────────────────────────────────── private function get_elementor_data( int $post_id ): ?array { $raw = get_post_meta( $post_id, '_elementor_data', true ); if ( ! $raw ) { return null; } $data = json_decode( $raw, true ); return ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) ? $data : null; } private function save_elementor_data( int $post_id, array $data ): bool { $encoded = wp_json_encode( $data ); // Guard: if JSON encoding fails (e.g. invalid UTF-8), do NOT overwrite with empty value. if ( $encoded === false ) { return false; } // wp_slash() counteracts update_post_meta's internal wp_unslash(), // preserving backslash sequences in JSON (e.g. \", \n, \\). update_post_meta( $post_id, '_elementor_data', wp_slash( $encoded ) ); update_post_meta( $post_id, '_elementor_edit_mode', 'builder' ); // Clear all Elementor caches so the page re-renders with updated content. delete_post_meta( $post_id, '_elementor_css' ); delete_post_meta( $post_id, '_elementor_element_cache' ); // Elementor Pro HTML cache delete_post_meta( $post_id, '_elementor_page_assets' ); return true; } /** * Recursively extract texts from Elementor data tree. */ private function extract_texts( array $elements, array &$texts, string $path = '' ): void { foreach ( $elements as $el ) { $id = $el['id'] ?? 'unknown'; $type = $el['widgetType'] ?? $el['elType'] ?? 'unknown'; $settings = $el['settings'] ?? []; // Common text-bearing fields foreach ( [ 'title', 'text', 'editor', 'html', 'caption', 'description', 'button_text', 'link_text' ] as $field ) { if ( isset( $settings[ $field ] ) && is_string( $settings[ $field ] ) && $settings[ $field ] !== '' ) { $texts[] = [ 'widget_id' => $id, 'widget_type' => $type, 'field' => $field, 'text' => $settings[ $field ], ]; } } // Recurse into children if ( ! empty( $el['elements'] ) ) { $this->extract_texts( $el['elements'], $texts, "$path/$id" ); } } } /** * Recursively find widget by ID and update a field in settings. */ private function update_widget_field( array &$elements, string $widget_id, string $field, $value, bool &$updated ): void { foreach ( $elements as &$el ) { if ( ( $el['id'] ?? '' ) === $widget_id ) { $el['settings'][ $field ] = $value; $updated = true; return; } if ( ! empty( $el['elements'] ) ) { $this->update_widget_field( $el['elements'], $widget_id, $field, $value, $updated ); if ( $updated ) return; } } } }