'POST', 'permission_callback' => array( Security::class, 'rest_can_edit_request' ), 'callback' => array( $this, 'save_selection' ), ) ); register_rest_route( 'wp-ai-builder/v1', '/selection/ack', array( 'methods' => 'POST', 'permission_callback' => array( Security::class, 'rest_can_edit_request' ), 'callback' => array( $this, 'ack_result' ), ) ); register_rest_route( 'wp-ai-builder/v1', '/selection', array( 'methods' => 'GET', 'permission_callback' => array( Security::class, 'rest_can_edit_request' ), 'callback' => array( $this, 'get_selection' ), ) ); } public function save_selection( WP_REST_Request $request ) { $user_id = get_current_user_id(); $editor = sanitize_key( $request->get_param( 'editor' ) ?: 'gutenberg' ); $post_id = intval( $request->get_param( 'post_id' ) ); if ( $post_id > 0 && ! Security::can_edit_post( $post_id ) ) { return new \WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this post.', 'advanced-animation' ), array( 'status' => rest_authorization_required_code() ) ); } if ( $request->get_param( 'clear' ) ) { self::clear_selection( $editor, $user_id ); return rest_ensure_response( array( 'success' => true, 'cleared' => true ) ); } $data = array( 'editor' => $editor, 'post_id' => $post_id ?: $request->get_param( 'post_id' ), 'clientIds' => $request->get_param( 'clientIds' ), 'blocks' => $request->get_param( 'blocks' ), 'indices' => $request->get_param( 'indices' ), 'html' => $request->get_param( 'html' ), 'post_content' => $request->get_param( 'post_content' ), 'elementData' => $request->get_param( 'elementData' ), 'selection_token' => sanitize_text_field( $request->get_param( 'selection_token' ) ?: '' ), 'semantic_version' => class_exists( Gutenberg_Transaction_Service::class ) ? Gutenberg_Transaction_Service::semantic_version( (string) $request->get_param( 'post_content' ) ) : sanitize_text_field( $request->get_param( 'semantic_version' ) ?: '' ), 'representation_version' => class_exists( Gutenberg_Transaction_Service::class ) ? Gutenberg_Transaction_Service::representation_version( (string) $request->get_param( 'post_content' ) ) : sanitize_text_field( $request->get_param( 'representation_version' ) ?: '' ), 'editor_session_id' => sanitize_text_field( $request->get_param( 'editor_session_id' ) ?: '' ), 'updated_at' => time(), ); self::store_selection( $editor, $data, $user_id ); return rest_ensure_response( array( 'success' => true ) ); } public function get_selection( WP_REST_Request $request ) { $user_id = get_current_user_id(); $editor = sanitize_key( $request->get_param( 'editor' ) ?: '' ); $post_id = intval( $request->get_param( 'post_id' ) ?: 0 ); $selection = self::read_selection( $editor ?: null ); $result = $editor ? self::read_result( $editor, $post_id, $user_id ) : null; if ( 'gutenberg' === $editor ) { if ( ! $post_id || intval( $selection['post_id'] ?? 0 ) !== $post_id ) { $selection = null; } } return rest_ensure_response( array( 'selection' => $selection ?: null, 'result' => $result ?: null, ) ); } public function ack_result( WP_REST_Request $request ) { $user_id = get_current_user_id(); $editor = sanitize_key( $request->get_param( 'editor' ) ?: 'gutenberg' ); $post_id = intval( $request->get_param( 'post_id' ) ?: 0 ); if ( 'gutenberg' === $editor && ! $post_id ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_post_missing' ) ); } $result = self::read_result( $editor, $post_id, $user_id ); if ( ! $result ) { if ( 'gutenberg' === $editor && self::has_gutenberg_results( $user_id ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_post_mismatch' ) ); } return rest_ensure_response( array( 'success' => false, 'code' => 'result_missing' ) ); } if ( 'gutenberg' === $editor && intval( $result['post_id'] ?? 0 ) !== $post_id ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_post_mismatch' ) ); } $result_id = sanitize_text_field( $request->get_param( 'result_id' ) ?: '' ); if ( $result_id && $result_id !== ( $result['result_id'] ?? '' ) ) { if ( 'gutenberg' === $editor && self::result_identity_is_for_another_post( $user_id, $post_id, $result_id, '' ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_post_mismatch' ) ); } return rest_ensure_response( array( 'success' => false, 'code' => 'result_mismatch' ) ); } $transaction_id = sanitize_text_field( $request->get_param( 'transaction_id' ) ?: '' ); if ( $transaction_id && $transaction_id !== ( $result['transaction_id'] ?? '' ) ) { if ( 'gutenberg' === $editor && self::result_identity_is_for_another_post( $user_id, $post_id, '', $transaction_id ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_post_mismatch' ) ); } return rest_ensure_response( array( 'success' => false, 'code' => 'result_mismatch' ) ); } $status = sanitize_key( $request->get_param( 'status' ) ?: 'applied' ); if ( ! in_array( $status, array( 'applied', 'conflict' ), true ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'ack_status_invalid' ) ); } if ( 'conflict' === $status ) { $reason = sanitize_key( $request->get_param( 'reason' ) ?: '' ); if ( 'gutenberg' !== $editor || ! in_array( $reason, array( 'editor_session_changed', 'editor_semantic_changed' ), true ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'conflict_reason_invalid' ) ); } if ( ! $result_id || ( ! empty( $result['transaction_id'] ) && ! $transaction_id ) ) { return rest_ensure_response( array( 'success' => false, 'code' => 'result_identity_missing' ) ); } if ( $transaction_id && class_exists( Gutenberg_Transaction_Service::class ) ) { $conflict = Gutenberg_Transaction_Service::acknowledge( $transaction_id, array( 'status' => 'conflict', 'reason' => $reason, 'force_conflict' => 'editor_session_changed' === $reason, 'editor_content' => (string) ( $request->get_param( 'editor_content' ) ?: '' ), ) ); if ( is_wp_error( $conflict ) && 'editor_semantic_conflict' !== $conflict->get_error_code() ) { return rest_ensure_response( array( 'success' => false, 'code' => $conflict->get_error_code(), 'message' => $conflict->get_error_message() ) ); } } self::clear_result( $editor, $post_id, $transaction_id, $result_id, $user_id ); return rest_ensure_response( array( 'success' => true, 'status' => 'delivery_conflict', 'reason' => $reason ) ); } if ( $transaction_id && class_exists( Gutenberg_Transaction_Service::class ) ) { $ack = Gutenberg_Transaction_Service::acknowledge( $transaction_id, array( 'status' => $status, 'editor_content' => (string) ( $request->get_param( 'editor_content' ) ?: '' ), 'semantic_version' => sanitize_text_field( $request->get_param( 'semantic_version' ) ?: '' ), 'representation_version' => sanitize_text_field( $request->get_param( 'representation_version' ) ?: '' ), 'tailwind_css' => (string) ( $request->get_param( 'tailwind_css' ) ?: '' ), 'validation' => $request->get_param( 'validation' ), ) ); if ( is_wp_error( $ack ) ) { return rest_ensure_response( array( 'success' => false, 'code' => $ack->get_error_code(), 'message' => $ack->get_error_message() ) ); } } self::clear_result( $editor, $post_id, $transaction_id, $result_id, $user_id ); return rest_ensure_response( array( 'success' => true ) ); } /** * Read the current user's stored selection. * * @return array|null Stored selection or null. */ public static function read_selection( ?string $editor = null ) { $user_id = get_current_user_id(); $records = self::read_editor_records( self::META_KEY, $user_id ); if ( $editor ) { return isset( $records[ $editor ] ) && is_array( $records[ $editor ] ) ? $records[ $editor ] : null; } foreach ( array( 'gutenberg', 'elementor' ) as $candidate ) { if ( ! empty( $records[ $candidate ] ) ) { return $records[ $candidate ]; } } return null; } public static function store_selection( string $editor, array $data, ?int $user_id = null ): void { $user_id = $user_id ?: get_current_user_id(); $records = self::read_editor_records( self::META_KEY, $user_id ); $data['editor'] = $editor; $data['updated_at'] = $data['updated_at'] ?? time(); $records[ $editor ] = $data; // Metadata APIs unslash values before storage. Preserve serialized block // markup and JSON attributes byte-for-byte across snapshot writes. update_user_meta( $user_id, self::META_KEY, wp_slash( $records ) ); } /** Remove one editor's selection snapshot without affecting other editors. */ public static function clear_selection( string $editor, ?int $user_id = null ): void { $user_id = $user_id ?: get_current_user_id(); $records = self::read_editor_records( self::META_KEY, $user_id ); unset( $records[ $editor ] ); update_user_meta( $user_id, self::META_KEY, wp_slash( $records ) ); } public static function is_fresh( array $selection ): bool { return ! empty( $selection['updated_at'] ) && time() - intval( $selection['updated_at'] ) <= self::SELECTION_TTL; } /** * Store a result for the JS editor to pick up. * * @param array $result Result data (blocks, indices). */ public static function store_result( $result ): bool { if ( ! is_array( $result ) ) { return false; } $user_id = get_current_user_id(); if ( is_array( $result ) && empty( $result['editor'] ) ) { $result['editor'] = 'gutenberg'; } $editor = sanitize_key( $result['editor'] ?? 'gutenberg' ); $post_id = intval( $result['post_id'] ?? 0 ); if ( 'gutenberg' === $editor && ! $post_id ) { return false; } self::track_unsplash_from_result( $result ); $records = self::read_result_records( $user_id ); $result['result_id'] = $result['result_id'] ?? wp_generate_uuid4(); $result['created_at'] = $result['created_at'] ?? time(); if ( 'gutenberg' === $editor ) { $queue = is_array( $records['gutenberg'] ?? null ) ? $records['gutenberg'] : array(); $queue[ $post_id ] = $result; $records['gutenberg'] = $queue; } else { $records[ $editor ] = $result; } update_user_meta( $user_id, self::RESULT_KEY, wp_slash( $records ) ); return true; } /** * Trigger Unsplash download tracking for images embedded in a staged result. * * @param array $result Staged editor result. */ private static function track_unsplash_from_result( array $result ): void { if ( ! class_exists( \AdvancedAddons\Tools\Search_Unsplash_Image_Tool::class ) ) { return; } $chunks = array( (string) ( $result['html'] ?? '' ), (string) ( $result['post_content'] ?? '' ), (string) ( $result['replacement_html'] ?? '' ), ); \AdvancedAddons\Tools\Search_Unsplash_Image_Tool::track_downloads_in_content( implode( "\n", $chunks ) ); } /** Read one staged result, strictly addressed by post for Gutenberg. */ public static function read_result( string $editor, int $post_id = 0, ?int $user_id = null ): ?array { $user_id = $user_id ?: get_current_user_id(); $records = self::read_result_records( $user_id ); if ( 'gutenberg' === $editor ) { if ( ! $post_id ) { return null; } $result = $records['gutenberg'][ $post_id ] ?? null; } else { $result = $records[ $editor ] ?? null; } if ( ! is_array( $result ) || sanitize_key( $result['editor'] ?? 'gutenberg' ) !== $editor ) { return null; } return $result; } /** Clear only the exact delivered result; other post queues remain intact. */ public static function clear_result( string $editor, int $post_id = 0, string $transaction_id = '', string $result_id = '', ?int $user_id = null ): bool { $user_id = $user_id ?: get_current_user_id(); $records = self::read_result_records( $user_id ); $result = self::read_result( $editor, $post_id, $user_id ); if ( ! $result ) { return false; } if ( $transaction_id && $transaction_id !== ( $result['transaction_id'] ?? '' ) ) { return false; } if ( $result_id && $result_id !== ( $result['result_id'] ?? '' ) ) { return false; } if ( 'gutenberg' === $editor ) { unset( $records['gutenberg'][ $post_id ] ); if ( empty( $records['gutenberg'] ) ) { unset( $records['gutenberg'] ); } } else { unset( $records[ $editor ] ); } update_user_meta( $user_id, self::RESULT_KEY, wp_slash( $records ) ); return true; } private static function has_gutenberg_results( int $user_id ): bool { $records = self::read_result_records( $user_id ); return ! empty( $records['gutenberg'] ); } private static function result_identity_is_for_another_post( int $user_id, int $post_id, string $result_id, string $transaction_id ): bool { $records = self::read_result_records( $user_id ); foreach ( $records['gutenberg'] ?? array() as $candidate_post_id => $candidate ) { if ( intval( $candidate_post_id ) === $post_id || ! is_array( $candidate ) ) { continue; } if ( $result_id && $result_id === ( $candidate['result_id'] ?? '' ) ) { return true; } if ( $transaction_id && $transaction_id === ( $candidate['transaction_id'] ?? '' ) ) { return true; } } return false; } /** Normalize legacy flat Gutenberg results into a post-keyed queue. */ private static function read_result_records( int $user_id ): array { $value = get_user_meta( $user_id, self::RESULT_KEY, true ); if ( ! is_array( $value ) || empty( $value ) ) { return array(); } if ( ! isset( $value['gutenberg'] ) && ! isset( $value['elementor'] ) ) { $editor = sanitize_key( $value['editor'] ?? 'gutenberg' ); $value = array( $editor => $value ); } $changed = false; $gutenberg = $value['gutenberg'] ?? array(); if ( self::is_result_record( $gutenberg ) ) { $post_id = intval( $gutenberg['post_id'] ?? 0 ); $value['gutenberg'] = $post_id ? array( $post_id => $gutenberg ) : array(); $changed = true; } if ( isset( $value['gutenberg'] ) && is_array( $value['gutenberg'] ) ) { foreach ( $value['gutenberg'] as $post_id => $result ) { $created_at = is_array( $result ) ? intval( $result['created_at'] ?? 0 ) : 0; if ( ! is_array( $result ) || intval( $result['post_id'] ?? 0 ) !== intval( $post_id ) || ! $created_at || time() - $created_at > self::RESULT_TTL ) { unset( $value['gutenberg'][ $post_id ] ); $changed = true; } } if ( empty( $value['gutenberg'] ) ) { unset( $value['gutenberg'] ); $changed = true; } } if ( $changed ) { update_user_meta( $user_id, self::RESULT_KEY, wp_slash( $value ) ); } return $value; } private static function is_result_record( $value ): bool { return is_array( $value ) && ( isset( $value['result_id'] ) || isset( $value['action'] ) || isset( $value['post_id'] ) ); } private static function read_editor_records( string $key, int $user_id ): array { $value = get_user_meta( $user_id, $key, true ); if ( ! is_array( $value ) || empty( $value ) ) { return array(); } if ( isset( $value['gutenberg'] ) || isset( $value['elementor'] ) ) { return $value; } $editor = sanitize_key( $value['editor'] ?? 'gutenberg' ); return array( $editor => $value ); } }