loadHTML( '
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $root = null; foreach ( $dom->getElementsByTagName( 'div' ) as $candidate ) { if ( '1' === $candidate->getAttribute( 'data-aa-semantic-root' ) ) { $root = $candidate; break; } } if ( ! $root ) { return html_entity_decode( trim( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } $nodes = array(); foreach ( $root->childNodes as $node ) { $canonical = self::canonical_converter_dom_node( $node ); if ( null !== $canonical ) { $nodes[] = $canonical; } } return self::canonical_json( $nodes ); } private static function has_converter_sibling( \DOMNode $node, string $direction ): bool { $sibling = $node->{$direction}; while ( $sibling ) { if ( XML_TEXT_NODE === $sibling->nodeType || XML_CDATA_SECTION_NODE === $sibling->nodeType ) { $value = (string) $sibling->nodeValue; if ( '' === trim( $value ) && false !== strpos( $value, "\n" ) ) { $sibling = $sibling->{$direction}; continue; } return true; } if ( XML_COMMENT_NODE !== $sibling->nodeType ) { return true; } $sibling = $sibling->{$direction}; } return false; } private static function canonical_converter_dom_node( \DOMNode $node, bool $preserve_whitespace = false ) { if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) { $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $node->nodeValue ); if ( $preserve_whitespace ) { return array( 'text' => $text ); } if ( '' === trim( $text ) && false !== strpos( $text, "\n" ) ) { return null; } $text = preg_replace( '/[\t\n\f\r ]+/', ' ', $text ); if ( ! self::has_converter_sibling( $node, 'previousSibling' ) ) { $text = preg_replace( '/^ /', '', $text ); } if ( ! self::has_converter_sibling( $node, 'nextSibling' ) ) { $text = preg_replace( '/ $/', '', $text ); } return '' === $text ? null : array( 'text' => $text ); } if ( XML_COMMENT_NODE === $node->nodeType ) { return array( 'comment' => trim( (string) $node->nodeValue ) ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return array( 'node' => (string) $node->nodeValue ); } $attributes = array(); if ( $node->hasAttributes() ) { foreach ( $node->attributes as $attribute ) { $attributes[ strtolower( $attribute->nodeName ) ] = (string) $attribute->nodeValue; } ksort( $attributes, SORT_STRING ); } $preserve = $preserve_whitespace || in_array( strtolower( $node->nodeName ), array( 'pre', 'textarea' ), true ); $children = array(); foreach ( $node->childNodes as $child ) { $canonical = self::canonical_converter_dom_node( $child, $preserve ); if ( null !== $canonical ) { $children[] = $canonical; } } return array( 'tag' => strtolower( $node->nodeName ), 'attributes' => $attributes, 'children' => $children ); } private static function canonical_block( array $block ): array { $name = (string) ( $block['blockName'] ?? '' ); $inner = array(); foreach ( (array) ( $block['innerBlocks'] ?? array() ) as $child ) { if ( is_array( $child ) ) { $inner[] = self::canonical_block( $child ); } } return array( 'name' => $name, 'attributes' => self::canonical_value( self::without_registered_defaults( $name, (array) ( $block['attrs'] ?? array() ) ) ), 'html' => self::canonical_html( (string) ( $block['innerHTML'] ?? '' ) ), 'innerBlocks' => $inner, ); } private static function without_registered_defaults( string $name, array $attributes ): array { $defaults = array(); if ( class_exists( '\\WP_Block_Type_Registry' ) ) { $registry = \WP_Block_Type_Registry::get_instance(); $type = method_exists( $registry, 'get_registered' ) ? $registry->get_registered( $name ) : null; foreach ( (array) ( $type->attributes ?? array() ) as $key => $schema ) { if ( is_array( $schema ) && array_key_exists( 'default', $schema ) ) { $defaults[ $key ] = $schema['default']; } } } if ( 'tailwind/html' === $name ) { $defaults = array_merge( array( 'tagName' => 'div', 'className' => '', 'content' => '', 'children' => 'blocks', 'src' => '', 'alt' => '', 'attrs' => array(), 'responsive' => array( 'sm' => '', 'md' => '', 'lg' => '', 'xl' => '' ), 'design' => array(), ), $defaults ); } foreach ( $defaults as $key => $default ) { if ( array_key_exists( $key, $attributes ) && self::canonical_json( self::canonical_value( $attributes[ $key ] ) ) === self::canonical_json( self::canonical_value( $default ) ) ) { unset( $attributes[ $key ] ); } } return $attributes; } private static function canonical_value( $value ) { if ( ! is_array( $value ) ) { return $value; } $is_list = empty( $value ) || array_keys( $value ) === range( 0, count( $value ) - 1 ); if ( $is_list ) { return array_map( array( self::class, 'canonical_value' ), $value ); } ksort( $value, SORT_STRING ); foreach ( $value as $key => $item ) { $value[ $key ] = self::canonical_value( $item ); } return $value; } private static function canonical_html( string $html ): string { $html = str_replace( array( "\r\n", "\r" ), "\n", $html ); if ( '' === $html || ! class_exists( '\\DOMDocument' ) ) { return html_entity_decode( trim( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } $dom = new \DOMDocument(); $previous = libxml_use_internal_errors( true ); $dom->loadHTML( '
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $root = null; foreach ( $dom->getElementsByTagName( 'div' ) as $candidate ) { if ( '1' === $candidate->getAttribute( 'data-aa-semantic-root' ) ) { $root = $candidate; break; } } if ( ! $root ) { return html_entity_decode( trim( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } $nodes = array(); foreach ( $root->childNodes as $node ) { $canonical = self::canonical_dom_node( $node ); if ( null !== $canonical ) { $nodes[] = $canonical; } } return self::canonical_json( $nodes ); } private static function canonical_dom_node( \DOMNode $node ) { if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) { $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $node->nodeValue ); if ( false !== strpos( $text, "\n" ) && '' === trim( $text ) ) { return null; } return array( 'text' => $text ); } if ( XML_COMMENT_NODE === $node->nodeType ) { return array( 'comment' => trim( (string) $node->nodeValue ) ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return array( 'node' => (string) $node->nodeValue ); } $attributes = array(); if ( $node->hasAttributes() ) { foreach ( $node->attributes as $attribute ) { $attributes[ strtolower( $attribute->nodeName ) ] = (string) $attribute->nodeValue; } ksort( $attributes, SORT_STRING ); } $children = array(); foreach ( $node->childNodes as $child ) { $canonical = self::canonical_dom_node( $child ); if ( null !== $canonical ) { $children[] = $canonical; } } return array( 'tag' => strtolower( $node->nodeName ), 'attributes' => $attributes, 'children' => $children ); } private static function canonical_json( $value ): string { $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; if ( defined( 'JSON_PRESERVE_ZERO_FRACTION' ) ) { $flags |= JSON_PRESERVE_ZERO_FRACTION; } return (string) ( function_exists( 'wp_json_encode' ) ? wp_json_encode( $value, $flags ) : json_encode( $value, $flags ) ); } private static function new_revision(): string { return wp_generate_uuid4(); } /** Upgrade durable 4.0-era byte-version transactions without losing work. */ private static function upgrade_transaction( array $transaction ): array { $candidate = (string) ( $transaction['candidate_content'] ?? '' ); $base_editor = (string) ( $transaction['base_editor_content'] ?? $candidate ); if ( empty( $transaction['transaction_revision'] ) ) { $transaction['transaction_revision'] = self::new_revision(); } $transaction['representation_version'] = self::representation_version( $candidate ); $transaction['semantic_version'] = self::semantic_version( $candidate ); $transaction['base_representation_version'] = (string) ( $transaction['base_representation_version'] ?? $transaction['base_version'] ?? '' ); $transaction['base_editor_representation_version'] = (string) ( $transaction['base_editor_representation_version'] ?? self::representation_version( $base_editor ) ); $transaction['base_editor_semantic_version'] = (string) ( $transaction['base_editor_semantic_version'] ?? self::semantic_version( $base_editor ) ); if ( ! isset( $transaction['base_semantic_version'] ) ) { $post = get_post( intval( $transaction['post_id'] ?? 0 ) ); $persisted = $post ? (string) $post->post_content : ''; $transaction['base_semantic_version'] = $post && hash_equals( $transaction['base_representation_version'], self::representation_version( $persisted ) ) ? self::semantic_version( $persisted ) : ''; } unset( $transaction['base_version'], $transaction['result_version'], $transaction['content_version'], $transaction['persisted_content_version'] ); return $transaction; } public static function get( string $transaction_id ) { $transactions = self::transactions(); if ( isset( $transactions[ $transaction_id ] ) ) { return self::upgrade_transaction( $transactions[ $transaction_id ] ); } $tombstones = self::tombstones(); if ( isset( $tombstones[ $transaction_id ] ) ) { $tombstone = $tombstones[ $transaction_id ]; $reason = (string) ( $tombstone['reason'] ?? 'expired' ); if ( 'committed' === $reason ) { return new WP_Error( 'already_committed', __( 'This Gutenberg transaction was already committed. No additional mutation was performed.', 'advanced-animation' ), self::recovery_data( $tombstone, false, array( 'inspect' ), true ) ); } if ( 'discarded' === $reason ) { return new WP_Error( 'transaction_discarded', __( 'This Gutenberg transaction was already discarded. No additional mutation was performed.', 'advanced-animation' ), self::recovery_data( $tombstone, false, array( 'inspect' ), true ) ); } return new WP_Error( 'transaction_expired', __( 'The Gutenberg transaction expired. Inspect the current page, then explicitly discard or start a user-approved recovery; do not repeat the mutation automatically.', 'advanced-animation' ), self::recovery_data( $tombstone, false, array( 'inspect', 'discard' ), true ) ); } return new WP_Error( 'transaction_missing', __( 'The Gutenberg transaction was not found.', 'advanced-animation' ) ); } public static function begin( int $post_id, string $transaction_id = '', string $expected_revision = '', string $editor_content = '' ) { if ( $transaction_id ) { $transaction = self::get( $transaction_id ); if ( is_wp_error( $transaction ) ) { return $transaction; } if ( intval( $transaction['post_id'] ) !== $post_id ) { return self::transaction_error( 'transaction_post_mismatch', __( 'The transaction belongs to a different post.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'discard' ) ); } if ( 'committed' === ( $transaction['status'] ?? '' ) ) { return self::transaction_error( 'already_committed', __( 'This transaction was already committed. No additional mutation was performed.', 'advanced-animation' ), $transaction, false, array( 'inspect' ) ); } if ( '' === $expected_revision ) { return self::transaction_error( 'expected_revision_required', __( 'expected_revision is required to continue a staged transaction.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } if ( 'staged' !== ( $transaction['status'] ?? '' ) || ! empty( $transaction['conflict_state'] ) ) { return self::transaction_error( 'transaction_conflicted', __( 'Resolve the active transaction conflict before continuing.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } if ( ! hash_equals( $transaction['transaction_revision'], $expected_revision ) ) { return self::transaction_error( 'transaction_revision_conflict', __( 'The staged transaction changed after it was read.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'commit', 'discard' ) ); } $active = self::active_record( $post_id ); if ( $active && (string) ( $active['transaction_id'] ?? '' ) !== $transaction_id ) { return self::active_error( $active ); } if ( ! $active && ! self::acquire_active( $post_id, $transaction_id ) ) { return self::active_error( self::active_record( $post_id ) ); } self::touch_active( $post_id, $transaction_id ); return $transaction; } $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'post_not_found', __( 'Post not found.', 'advanced-animation' ) ); } if ( ! Security::can_edit_post( $post_id ) ) { return new WP_Error( 'edit_forbidden', __( 'Sorry, you are not allowed to edit this post.', 'advanced-animation' ) ); } $persisted_content = (string) $post->post_content; $base_representation = self::representation_version( $persisted_content ); $base_semantic = self::semantic_version( $persisted_content ); $content = '' !== $editor_content ? $editor_content : $persisted_content; $representation = self::representation_version( $content ); $semantic = self::semantic_version( $content ); if ( $expected_revision ) { return new WP_Error( 'transaction_missing', __( 'expected_revision is valid only when continuing an existing transaction.', 'advanced-animation' ) ); } $legacy_active = self::staged_transaction_for_post( $post_id ); if ( $legacy_active ) { self::acquire_active( $post_id, (string) $legacy_active['id'] ); return self::active_error( array( 'transaction_id' => (string) $legacy_active['id'], 'transaction_revision' => (string) ( $legacy_active['transaction_revision'] ?? '' ), ) ); } $new_transaction_id = wp_generate_uuid4(); if ( ! self::acquire_active( $post_id, $new_transaction_id ) ) { return self::active_error( self::active_record( $post_id ) ); } $transaction = array( 'id' => $new_transaction_id, 'post_id' => $post_id, 'user_id' => get_current_user_id(), 'transaction_revision' => self::new_revision(), 'base_representation_version' => $base_representation, 'base_semantic_version' => $base_semantic, 'base_editor_representation_version' => $representation, 'base_editor_semantic_version' => $semantic, 'representation_version' => $representation, 'semantic_version' => $semantic, 'base_editor_content' => $content, 'candidate_content' => $content, 'status' => 'preflight', 'editor_delivery' => 'queued', 'editor_delivery_updated_at' => time(), 'created_at' => time(), 'updated_at' => time(), 'mutations' => array(), ); return $transaction; } public static function stage( array $transaction, string $candidate_content, string $action, array $details = array() ) { $validation = self::validate_content( $candidate_content ); if ( ! $validation['valid'] ) { self::fail_preflight( $transaction ); return self::transaction_error( 'candidate_invalid', __( 'The staged block content failed validation and was not delivered to the editor.', 'advanced-animation' ), $transaction, false, array( 'inspect' ), true, $validation ); } $current_content = (string) ( $transaction['candidate_content'] ?? '' ); if ( hash_equals( self::semantic_version( $current_content ), self::semantic_version( $candidate_content ) ) ) { self::fail_preflight( $transaction ); return self::transaction_error( 'already_present', __( 'The requested content is already present. No additional mutation was staged.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } $inserted_content = (string) ( $details['inserted_content'] ?? '' ); if ( 'insert' === $action && empty( $details['allow_duplicate'] ) && '' !== $inserted_content && false !== strpos( $current_content, $inserted_content ) ) { self::fail_preflight( $transaction ); return self::transaction_error( 'already_present', __( 'An identical block insertion is already present. Set allow_duplicate only when the user explicitly requested duplicate generic content.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } $active = self::active_record( intval( $transaction['post_id'] ) ); if ( ! $active || (string) ( $active['transaction_id'] ?? '' ) !== (string) $transaction['id'] ) { return self::active_error( $active ); } $expected_editor_semantic = (string) ( $transaction['semantic_version'] ?? self::semantic_version( $current_content ) ); $transaction['candidate_content'] = $candidate_content; $transaction['representation_version'] = self::representation_version( $candidate_content ); $transaction['semantic_version'] = self::semantic_version( $candidate_content ); $transaction['transaction_revision'] = self::new_revision(); $transaction['updated_at'] = time(); $transaction['status'] = 'staged'; $transaction['editor_delivery'] = 'queued'; $transaction['editor_delivery_updated_at'] = time(); unset( $transaction['editor_delivery_reason'], $transaction['conflict_state'], $transaction['conflict'] ); unset( $transaction['editor_ack'] ); $transaction['mutations'][] = array_merge( array( 'action' => $action, 'time' => time() ), $details ); self::put( $transaction ); self::touch_active( intval( $transaction['post_id'] ), (string) $transaction['id'] ); $selection = SelectionBridge::read_selection( 'gutenberg' ); $editor_session_id = ''; if ( $selection && SelectionBridge::is_fresh( $selection ) && intval( $selection['post_id'] ?? 0 ) === intval( $transaction['post_id'] ) ) { $editor_session_id = sanitize_text_field( $selection['editor_session_id'] ?? '' ); } self::queue_delivery( $transaction, $action, $expected_editor_semantic, $editor_session_id, $details ); return $transaction; } public static function discard( string $transaction_id, string $expected_revision = '', string $terminal_reason = 'explicit_discard' ) { $transactions = self::transactions(); if ( ! isset( $transactions[ $transaction_id ] ) ) { return self::get( $transaction_id ); } $transaction = $transactions[ $transaction_id ]; if ( '' === $expected_revision ) { return self::transaction_error( 'expected_revision_required', __( 'expected_revision is required to discard a staged transaction.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'discard' ) ); } if ( ! hash_equals( (string) ( $transaction['transaction_revision'] ?? '' ), $expected_revision ) ) { return self::transaction_error( 'transaction_revision_conflict', __( 'The transaction revision changed. Inspect the current transaction before discarding it.', 'advanced-animation' ), $transaction, true, array( 'inspect', 'discard' ) ); } if ( 'committed' === ( $transaction['status'] ?? '' ) ) { return self::transaction_error( 'already_committed', __( 'This transaction was already committed and cannot be discarded. No content was changed.', 'advanced-animation' ), $transaction, false, array( 'inspect' ) ); } unset( $transactions[ $transaction_id ] ); update_user_meta( get_current_user_id(), self::META_KEY, wp_slash( $transactions ) ); self::release_active( intval( $transaction['post_id'] ?? 0 ), $transaction_id ); $transaction['terminal_reason'] = sanitize_key( $terminal_reason ) ?: 'explicit_discard'; self::add_tombstone( $transaction, 'discarded' ); SelectionBridge::clear_result( 'gutenberg', intval( $transaction['post_id'] ?? 0 ), $transaction_id ); return $transaction; } /** * Record delivery without changing the transaction's staged/committed state. */ public static function mark_editor_delivery( string $transaction_id, string $status, string $reason = '' ) { $status = sanitize_key( $status ); if ( ! in_array( $status, array( 'queued', 'delivered', 'applied', 'delivery_conflict' ), true ) ) { return new WP_Error( 'editor_delivery_invalid', __( 'The editor delivery status is invalid.', 'advanced-animation' ) ); } $transaction = self::get( $transaction_id ); if ( is_wp_error( $transaction ) ) { return $transaction; } $transaction['editor_delivery'] = $status; $transaction['editor_delivery_updated_at'] = time(); if ( '' !== $reason ) { $transaction['editor_delivery_reason'] = sanitize_key( $reason ); } self::put( $transaction ); return $transaction; } public static function acknowledge( string $transaction_id, array $data ) { $transaction = self::get( $transaction_id ); if ( is_wp_error( $transaction ) ) { return $transaction; } $editor_content = (string) ( $data['editor_content'] ?? '' ); if ( '' === $editor_content ) { $selection = SelectionBridge::read_selection( 'gutenberg' ); if ( is_array( $selection ) && intval( $selection['post_id'] ?? 0 ) === intval( $transaction['post_id'] ?? 0 ) ) { $editor_content = (string) ( $selection['post_content'] ?? '' ); } } $editor_semantic = '' !== $editor_content ? self::semantic_version( $editor_content ) : ''; $editor_representation = '' !== $editor_content ? self::representation_version( $editor_content ) : ''; $forced_reason = ! empty( $data['force_conflict'] ) ? sanitize_key( $data['reason'] ?? 'editor_session_changed' ) : ''; if ( $forced_reason || ! $editor_semantic || ! hash_equals( (string) $transaction['semantic_version'], $editor_semantic ) ) { $transaction['editor_delivery'] = 'delivery_conflict'; $transaction['editor_delivery_reason'] = $forced_reason ?: 'editor_semantic_conflict'; $transaction['conflict_state'] = 'delivery_conflict'; $transaction['conflict'] = array( 'candidate_semantic_version' => (string) $transaction['semantic_version'], 'editor_semantic_version' => $editor_semantic, 'editor_representation_version' => $editor_representation, 'reason' => $forced_reason ?: 'editor_semantic_conflict', ); $transaction['editor_delivery_updated_at'] = time(); $transaction['updated_at'] = time(); self::put( $transaction ); self::touch_active( intval( $transaction['post_id'] ?? 0 ), $transaction_id ); return self::transaction_error( 'editor_semantic_conflict', __( 'The live editor differs semantically from the staged transaction. The staged work was retained.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } $transaction['editor_ack'] = array( 'status' => sanitize_key( $data['status'] ?? 'applied' ), 'semantic_version' => $editor_semantic, 'representation_version' => $editor_representation, 'tailwind_css' => Security::sanitize_css( (string) ( $data['tailwind_css'] ?? '' ) ), 'validation' => is_array( $data['validation'] ?? null ) ? $data['validation'] : array(), 'acknowledged_at' => time(), ); $transaction['editor_delivery'] = 'applied'; $transaction['editor_delivery_updated_at'] = time(); $transaction['editor_delivery_reason'] = 'browser_acknowledged'; unset( $transaction['conflict_state'], $transaction['conflict'] ); $transaction['updated_at'] = time(); self::put( $transaction ); if ( 'staged' === ( $transaction['status'] ?? '' ) ) { self::touch_active( intval( $transaction['post_id'] ?? 0 ), $transaction_id ); } if ( 'committed' === ( $transaction['status'] ?? '' ) && ! empty( $transaction['editor_ack']['tailwind_css'] ) && hash_equals( $transaction['semantic_version'], (string) $transaction['editor_ack']['semantic_version'] ) ) { $post = get_post( intval( $transaction['post_id'] ) ); if ( $post && self::content_equivalent( (string) $transaction['candidate_content'], (string) $post->post_content ) ) { $persisted_version = self::representation_version( (string) $post->post_content ); update_post_meta( $post->ID, '_tailwind_css', $transaction['editor_ack']['tailwind_css'] ); update_post_meta( $post->ID, '_tailwind_css_source_hash', $persisted_version ); $transaction['persisted_representation_version'] = $persisted_version; $transaction['reconciled'] = ! hash_equals( $transaction['representation_version'], $persisted_version ); self::put( $transaction ); } } return $transaction; } /** Resolve a retained semantic conflict without merging or discarding work. */ public static function resolve( string $transaction_id, string $expected_revision, string $strategy, string $editor_content = '' ) { $transaction = self::get( $transaction_id ); if ( is_wp_error( $transaction ) ) { return $transaction; } if ( '' === $expected_revision || ! hash_equals( (string) $transaction['transaction_revision'], $expected_revision ) ) { return self::transaction_error( 'transaction_revision_conflict', __( 'The transaction revision changed before conflict resolution.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } if ( empty( $transaction['conflict_state'] ) ) { return self::transaction_error( 'transaction_not_conflicted', __( 'This transaction has no conflict to resolve.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } if ( ! in_array( $strategy, array( 'keep_transaction', 'adopt_editor' ), true ) ) { return self::transaction_error( 'resolution_strategy_invalid', __( 'Use keep_transaction or adopt_editor.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } $post = get_post( intval( $transaction['post_id'] ?? 0 ) ); if ( ! $post ) { return new WP_Error( 'post_not_found', __( 'Post not found.', 'advanced-animation' ) ); } if ( 'persisted_conflict' === ( $transaction['conflict_state'] ?? '' ) ) { $transaction['base_representation_version'] = self::representation_version( (string) $post->post_content ); $transaction['base_semantic_version'] = self::semantic_version( (string) $post->post_content ); } $previous_editor_semantic = (string) ( $transaction['conflict']['editor_semantic_version'] ?? '' ); if ( 'adopt_editor' === $strategy ) { if ( '' === $editor_content ) { $selection = SelectionBridge::read_selection( 'gutenberg' ); if ( is_array( $selection ) && intval( $selection['post_id'] ?? 0 ) === intval( $transaction['post_id'] ?? 0 ) ) { $editor_content = (string) ( $selection['post_content'] ?? '' ); } } if ( '' === $editor_content ) { return self::transaction_error( 'editor_snapshot_required', __( 'adopt_editor requires the current serialized editor content.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } $validation = self::validate_content( $editor_content ); if ( ! $validation['valid'] ) { return self::transaction_error( 'candidate_invalid', __( 'The editor snapshot is not a valid transaction candidate.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ), true, $validation ); } $transaction['candidate_content'] = $editor_content; $transaction['representation_version'] = self::representation_version( $editor_content ); $transaction['semantic_version'] = self::semantic_version( $editor_content ); $transaction['base_editor_content'] = $editor_content; $transaction['base_editor_representation_version'] = $transaction['representation_version']; $transaction['base_editor_semantic_version'] = $transaction['semantic_version']; $transaction['editor_delivery'] = 'applied'; $transaction['editor_delivery_reason'] = 'adopted_editor'; $transaction['editor_ack'] = array( 'status' => 'applied', 'semantic_version' => $transaction['semantic_version'], 'representation_version' => $transaction['representation_version'], 'acknowledged_at' => time(), ); } else { $transaction['editor_delivery'] = 'queued'; $transaction['editor_delivery_reason'] = 'keep_transaction'; unset( $transaction['editor_ack'] ); } $transaction['transaction_revision'] = self::new_revision(); $transaction['status'] = 'staged'; $transaction['updated_at'] = time(); $transaction['editor_delivery_updated_at'] = time(); $transaction['mutations'][] = array( 'action' => 'resolve', 'strategy' => $strategy, 'time' => time() ); unset( $transaction['conflict_state'], $transaction['conflict'] ); self::put( $transaction ); self::touch_active( intval( $transaction['post_id'] ), $transaction_id ); if ( 'keep_transaction' === $strategy ) { self::queue_delivery( $transaction, 'resolve', $previous_editor_semantic ); } return $transaction; } private static function queue_delivery( array $transaction, string $action, string $expected_editor_semantic = '', string $editor_session_id = '', array $details = array() ): void { if ( '' === $editor_session_id ) { $selection = SelectionBridge::read_selection( 'gutenberg' ); if ( $selection && SelectionBridge::is_fresh( $selection ) && intval( $selection['post_id'] ?? 0 ) === intval( $transaction['post_id'] ?? 0 ) ) { $editor_session_id = sanitize_text_field( $selection['editor_session_id'] ?? '' ); } } SelectionBridge::store_result( array( 'editor' => 'gutenberg', 'action' => 'full_page_edit', 'mutation_action' => $action, 'transaction_id' => $transaction['id'], 'post_id' => $transaction['post_id'], 'post_content' => (string) $transaction['candidate_content'], 'transaction_revision' => (string) $transaction['transaction_revision'], 'semantic_version' => (string) $transaction['semantic_version'], 'representation_version' => (string) $transaction['representation_version'], 'delivery_status' => 'pending', 'editor_session_id' => $editor_session_id, 'expected_editor_semantic_version' => $expected_editor_semantic, 'indices' => array_values( array_map( 'intval', (array) ( $details['affected_indices'] ?? array() ) ) ), 'focus_index' => isset( $details['focus_index'] ) ? intval( $details['focus_index'] ) : null, ) ); } public static function commit( string $transaction_id, string $target = 'save', string $expected_revision = '', bool $allow_runtime_fallback = false ) { $transaction = self::get( $transaction_id ); if ( is_wp_error( $transaction ) ) { return $transaction; } if ( 'committed' === ( $transaction['status'] ?? '' ) ) { $post = get_post( intval( $transaction['post_id'] ) ); if ( $post && self::content_equivalent( (string) $transaction['candidate_content'], (string) $post->post_content ) ) { $persisted_representation = self::representation_version( (string) $post->post_content ); $persisted_semantic = self::semantic_version( (string) $post->post_content ); return array( 'transaction' => $transaction, 'post' => $post, 'revision_id' => intval( $transaction['revision_id'] ?? 0 ), 'validation' => self::validate_content( (string) $post->post_content ), 'already_committed' => true, 'reconciled' => ! hash_equals( (string) $transaction['representation_version'], $persisted_representation ), 'persisted_representation_version' => $persisted_representation, 'persisted_semantic_version' => $persisted_semantic, ); } return self::transaction_error( 'persisted_content_conflict', __( 'The committed transaction no longer matches persisted content.', 'advanced-animation' ), $transaction, false, array( 'inspect' ) ); } if ( '' === $expected_revision ) { return self::transaction_error( 'expected_revision_required', __( 'expected_revision is required to commit a staged transaction.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } if ( ! hash_equals( (string) $transaction['transaction_revision'], $expected_revision ) ) { return self::transaction_error( 'transaction_revision_conflict', __( 'The staged transaction changed after it was read.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'commit', 'discard' ) ); } if ( ! empty( $transaction['conflict_state'] ) ) { return self::transaction_error( 'transaction_conflicted', __( 'Resolve or discard the retained transaction conflict before committing.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } $post = get_post( intval( $transaction['post_id'] ) ); if ( ! $post ) { return new WP_Error( 'post_not_found', __( 'Post not found.', 'advanced-animation' ) ); } if ( ! Security::can_edit_post( intval( $post->ID ) ) ) { return new WP_Error( 'edit_forbidden', __( 'Sorry, you are not allowed to edit this post.', 'advanced-animation' ) ); } $persisted_representation = self::representation_version( (string) $post->post_content ); $persisted_semantic = self::semantic_version( (string) $post->post_content ); if ( self::content_equivalent( (string) $transaction['candidate_content'], (string) $post->post_content ) ) { $transaction['status'] = 'committed'; $transaction['updated_at'] = time(); $transaction['revision_id'] = intval( $transaction['revision_id'] ?? 0 ); $transaction['persisted_representation_version'] = $persisted_representation; $transaction['persisted_semantic_version'] = $persisted_semantic; $transaction['reconciled'] = ! hash_equals( (string) $transaction['representation_version'], $persisted_representation ); self::put( $transaction ); self::release_active( intval( $transaction['post_id'] ), $transaction_id ); return array( 'transaction' => $transaction, 'post' => $post, 'revision_id' => intval( $transaction['revision_id'] ), 'validation' => self::validate_content( (string) $post->post_content ), 'already_committed' => true, 'reconciled' => (bool) $transaction['reconciled'], 'persisted_representation_version' => $persisted_representation, 'persisted_semantic_version' => $persisted_semantic, ); } if ( empty( $transaction['base_semantic_version'] ) || ! hash_equals( (string) $transaction['base_semantic_version'], $persisted_semantic ) ) { $transaction['conflict_state'] = 'persisted_conflict'; $transaction['conflict'] = array( 'candidate_semantic_version' => (string) $transaction['semantic_version'], 'persisted_semantic_version' => $persisted_semantic, 'persisted_representation_version' => $persisted_representation, ); $transaction['updated_at'] = time(); self::put( $transaction ); self::touch_active( intval( $transaction['post_id'] ), $transaction_id ); return self::transaction_error( 'persisted_content_conflict', __( 'The persisted page changed semantically while this transaction was staged. The staged candidate was retained.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ) ); } $validation = self::validate_content( $transaction['candidate_content'] ); if ( ! $validation['valid'] ) { return self::transaction_error( 'candidate_invalid', __( 'The staged block content failed validation.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'discard' ), true, $validation ); } $editor_ack = $transaction['editor_ack'] ?? array(); $tailwind_ready = ! empty( $editor_ack['tailwind_css'] ) && hash_equals( (string) $transaction['semantic_version'], (string) ( $editor_ack['semantic_version'] ?? '' ) ); if ( self::contains_tailwind_blocks( $transaction['candidate_content'] ) && ! $tailwind_ready && ! $allow_runtime_fallback ) { self::touch_active( intval( $transaction['post_id'] ), $transaction_id ); return new WP_Error( 'tailwind_compile_pending', __( 'Tailwind CSS is not ready. Keep this transaction staged, poll get-page-content, and retry after the active editor acknowledges matching CSS. Use allow_runtime_fallback only when editor compilation is unavailable.', 'advanced-animation' ), array_merge( self::recovery_data( $transaction, true, array( 'commit', 'discard' ), true ), array( 'editor_acknowledged' => ! empty( $editor_ack ), 'tailwind_css_ready' => false, ) ) ); } $snapshot = array( 'post_content' => (string) $post->post_content, 'post_status' => $post->post_status, 'tailwind_css' => get_post_meta( $post->ID, '_tailwind_css', true ), 'tailwind_hash' => get_post_meta( $post->ID, '_tailwind_css_source_hash', true ), ); $revision_id = 0; if ( function_exists( '_wp_put_post_revision' ) && wp_revisions_enabled( $post ) ) { $revision_id = _wp_put_post_revision( $post ); if ( is_wp_error( $revision_id ) ) { return $revision_id; } } $update = array( 'ID' => $post->ID, 'post_content' => $transaction['candidate_content'] ); if ( 'publish' === $target ) { if ( ! current_user_can( 'publish_post', $post->ID ) ) { return new WP_Error( 'publish_forbidden', __( 'The current user cannot publish this post.', 'advanced-animation' ) ); } $update['post_status'] = 'publish'; } $updated = self::update_post_without_revisions( $update ); if ( is_wp_error( $updated ) ) { return $updated; } $persisted = get_post( $post->ID ); $post_validation = self::validate_content( (string) $persisted->post_content ); $written_semantic = self::semantic_version( (string) $persisted->post_content ); if ( ! $post_validation['valid'] || ! hash_equals( (string) $transaction['semantic_version'], $written_semantic ) ) { self::update_post_without_revisions( array( 'ID' => $post->ID, 'post_content' => $snapshot['post_content'], 'post_status' => $snapshot['post_status'] ) ); update_post_meta( $post->ID, '_tailwind_css', $snapshot['tailwind_css'] ); update_post_meta( $post->ID, '_tailwind_css_source_hash', $snapshot['tailwind_hash'] ); $transaction['conflict_state'] = 'persisted_conflict'; $transaction['conflict'] = array( 'candidate_semantic_version' => (string) $transaction['semantic_version'], 'persisted_semantic_version' => $written_semantic, ); $transaction['updated_at'] = time(); self::put( $transaction ); self::touch_active( intval( $transaction['post_id'] ), $transaction_id ); return self::transaction_error( 'commit_rolled_back', __( 'Post-write validation failed; the previous page content was restored and the staged candidate was retained.', 'advanced-animation' ), $transaction, false, array( 'inspect', 'resolve', 'discard' ), true, $post_validation ); } if ( $tailwind_ready ) { update_post_meta( $post->ID, '_tailwind_css', Security::sanitize_css( (string) $editor_ack['tailwind_css'] ) ); update_post_meta( $post->ID, '_tailwind_css_source_hash', self::representation_version( (string) $persisted->post_content ) ); } else { delete_post_meta( $post->ID, '_tailwind_css_source_hash' ); } $transaction['status'] = 'committed'; $transaction['updated_at'] = time(); $transaction['revision_id'] = intval( $revision_id ); $transaction['persisted_representation_version'] = self::representation_version( (string) $persisted->post_content ); $transaction['persisted_semantic_version'] = self::semantic_version( (string) $persisted->post_content ); $transaction['reconciled'] = false; self::put( $transaction ); self::release_active( intval( $transaction['post_id'] ), $transaction_id ); return array( 'transaction' => $transaction, 'post' => $persisted, 'revision_id' => intval( $revision_id ), 'validation' => $post_validation, 'reconciled' => false, 'persisted_representation_version' => $transaction['persisted_representation_version'], 'persisted_semantic_version' => $transaction['persisted_semantic_version'], ); } public static function fail_preflight( array $transaction ): void { if ( 'preflight' === ( $transaction['status'] ?? '' ) ) { self::release_active( intval( $transaction['post_id'] ?? 0 ), (string) ( $transaction['id'] ?? '' ) ); } } public static function recovery_data( array $transaction, bool $retryable, array $permitted_next_actions, bool $must_not_mutate ): array { $recovery = array( 'transaction_id' => (string) ( $transaction['id'] ?? $transaction['transaction_id'] ?? '' ), 'transaction_revision' => (string) ( $transaction['transaction_revision'] ?? '' ), 'semantic_version' => (string) ( $transaction['semantic_version'] ?? '' ), 'representation_version' => (string) ( $transaction['representation_version'] ?? '' ), 'transaction_state' => sanitize_key( $transaction['conflict_state'] ?? $transaction['status'] ?? '' ), 'retryable' => $retryable, 'recovery_required' => ! $retryable, 'permitted_next_actions' => array_values( $permitted_next_actions ), 'must_not_mutate' => $must_not_mutate, ); if ( ! empty( $transaction['persisted_semantic_version'] ) ) { $recovery['persisted_semantic_version'] = (string) $transaction['persisted_semantic_version']; } if ( ! empty( $transaction['persisted_representation_version'] ) ) { $recovery['persisted_representation_version'] = (string) $transaction['persisted_representation_version']; } if ( ! empty( $transaction['conflict'] ) && is_array( $transaction['conflict'] ) ) { $recovery['conflict'] = $transaction['conflict']; } if ( ! empty( $transaction['editor_delivery'] ) ) { $recovery['editor_delivery'] = sanitize_key( $transaction['editor_delivery'] ); } if ( ! empty( $transaction['terminal_reason'] ) ) { $recovery['terminal_reason'] = sanitize_key( $transaction['terminal_reason'] ); } return $recovery; } private static function transaction_error( string $code, string $message, array $transaction, bool $retryable, array $actions, bool $must_not_mutate = true, array $extra = array() ): WP_Error { return new WP_Error( $code, $message, array_merge( self::recovery_data( $transaction, $retryable, $actions, $must_not_mutate ), $extra ) ); } public static function contains_tailwind_blocks( string $content ): bool { foreach ( parse_blocks( $content ) as $block ) { if ( 'tailwind/html' === ( $block['blockName'] ?? '' ) ) { return true; } if ( ! empty( $block['innerBlocks'] ) && self::blocks_contain_tailwind( (array) $block['innerBlocks'] ) ) { return true; } } return false; } /** Semantic equality is independent of Gutenberg's serialized representation. */ public static function content_equivalent( string $candidate, string $persisted ): bool { if ( hash_equals( self::representation_version( $candidate ), self::representation_version( $persisted ) ) ) { return true; } return hash_equals( self::semantic_version( $candidate ), self::semantic_version( $persisted ) ); } private static function blocks_contain_tailwind( array $blocks ): bool { foreach ( $blocks as $block ) { if ( 'tailwind/html' === ( $block['blockName'] ?? '' ) ) return true; if ( ! empty( $block['innerBlocks'] ) && self::blocks_contain_tailwind( (array) $block['innerBlocks'] ) ) return true; } return false; } public static function describe_content( string $content, ?array $selection = null, bool $render = true ): array { $parsed = parse_blocks( $content ); $items = array(); $visible = 0; foreach ( $parsed as $raw_index => $block ) { if ( empty( $block['blockName'] ) ) { continue; } $serialized = serialize_block( $block ); $client_id = null; if ( $selection && isset( $selection['indices'], $selection['clientIds'] ) ) { $position = array_search( $visible, (array) $selection['indices'], true ); if ( false !== $position ) { $client_id = $selection['clientIds'][ $position ] ?? null; } } $items[] = array( 'index' => $visible, 'raw_index' => $raw_index, 'name' => $block['blockName'], 'html_id' => self::root_html_id( $block ), 'client_id' => $client_id, 'attributes' => $block['attrs'] ?? array(), 'rendered_html' => $render && function_exists( 'render_block' ) ? render_block( $block ) : ( $block['innerHTML'] ?? '' ), 'serialized_markup' => $serialized, 'block_semantic_version' => self::semantic_version( $serialized ), ); $visible++; } return $items; } private static function root_html_id( array $block ): string { $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); foreach ( array( $attrs['id'] ?? '', $attrs['anchor'] ?? '', $attrs['attrs']['id'] ?? '', ) as $candidate ) { $id = trim( (string) $candidate ); if ( $id ) return $id; } if ( preg_match( '/^\\s*<[^>]+\\sid=["\']([^"\']+)["\']/i', (string) ( $block['innerHTML'] ?? '' ), $match ) ) { return html_entity_decode( $match[1], ENT_QUOTES, 'UTF-8' ); } return ''; } public static function validate_content( string $content ): array { $parsed = parse_blocks( $content ); $described = self::describe_content( $content, null, false ); $errors = array(); $warnings = array(); $ids = array(); $attachment_ids = array(); foreach ( $described as $item ) { if ( class_exists( '\\WP_Block_Type_Registry' ) && ! \WP_Block_Type_Registry::get_instance()->is_registered( $item['name'] ) ) { $errors[] = array( 'code' => 'unregistered_block', 'block' => $item['name'], 'index' => $item['index'] ); } if ( preg_match_all( '/\sid=["\']([^"\']+)["\']/i', $item['serialized_markup'], $matches ) ) { foreach ( $matches[1] as $id ) { if ( isset( $ids[ $id ] ) ) { $errors[] = array( 'code' => 'duplicate_html_id', 'id' => $id ); } $ids[ $id ] = true; } } } if ( preg_match( '/(?:data:image\/|blob:)/i', $content ) ) { $errors[] = array( 'code' => 'unresolved_embedded_asset' ); } if ( function_exists( 'attachment_url_to_postid' ) && preg_match_all( '#https?://[^"\'\s)]+/wp-content/uploads/[^"\'\s)]+#i', $content, $asset_matches ) ) { foreach ( array_unique( $asset_matches[0] ) as $url ) { $attachment_id = intval( attachment_url_to_postid( html_entity_decode( $url, ENT_QUOTES, 'UTF-8' ) ) ); if ( ! $attachment_id || 'attachment' !== get_post_type( $attachment_id ) ) { $errors[] = array( 'code' => 'attachment_missing', 'url' => $url ); } else { $attachment_ids[] = $attachment_id; } } } $roundtrip = serialize_blocks( $parsed ); $second_roundtrip = serialize_blocks( parse_blocks( $roundtrip ) ); $roundtrip_stable = hash_equals( self::representation_version( $roundtrip ), self::representation_version( $second_roundtrip ) ); if ( ! $roundtrip_stable ) { $errors[] = array( 'code' => 'roundtrip_serialization_mismatch' ); } $roundtrip_blocks = array_values( array_filter( parse_blocks( $roundtrip ), function ( $block ) { return ! empty( $block['blockName'] ); } ) ); if ( count( $described ) !== count( $roundtrip_blocks ) ) { $errors[] = array( 'code' => 'roundtrip_block_count_mismatch' ); } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'warnings' => $warnings, 'top_level_blocks' => count( $described ), 'roundtrip_stable' => $roundtrip_stable, 'duplicate_html_ids' => count( array_filter( $errors, function ( $error ) { return 'duplicate_html_id' === ( $error['code'] ?? '' ); } ) ), 'attachment_ids' => array_values( array_unique( $attachment_ids ) ), ); } private static function update_post_without_revisions( array $update ) { $disable_revisions = static function () { return 0; }; add_filter( 'wp_revisions_to_keep', $disable_revisions, PHP_INT_MAX, 2 ); try { // wp_update_post() passes caller-supplied fields to wp_insert_post(), // which unslashes them. Slash transaction markup exactly once so JSON // attributes and escaped block content survive byte-for-byte validation. return wp_update_post( wp_slash( $update ), true ); } finally { remove_filter( 'wp_revisions_to_keep', $disable_revisions, PHP_INT_MAX ); } } private static function transactions(): array { $user_id = get_current_user_id(); $transactions = get_user_meta( $user_id, self::META_KEY, true ); $transactions = is_array( $transactions ) ? $transactions : array(); $now = time(); $changed = false; foreach ( $transactions as $id => $transaction ) { if ( empty( $transaction['updated_at'] ) || $now - intval( $transaction['updated_at'] ) > self::TTL ) { unset( $transactions[ $id ] ); $changed = true; self::release_active( intval( $transaction['post_id'] ?? 0 ), (string) $id ); self::add_tombstone( $transaction, 'committed' === ( $transaction['status'] ?? '' ) ? 'committed' : 'expired' ); continue; } $upgraded = self::upgrade_transaction( (array) $transaction ); if ( $upgraded !== $transaction ) { $transactions[ $id ] = $upgraded; $changed = true; } } if ( $changed ) { update_user_meta( $user_id, self::META_KEY, wp_slash( $transactions ) ); } return $transactions; } private static function put( array $transaction ): void { $transactions = self::transactions(); $transactions[ $transaction['id'] ] = $transaction; // update_user_meta() unslashes recursively. Slash transaction snapshots // once so escaped Gutenberg JSON survives staging and later commit. update_user_meta( get_current_user_id(), self::META_KEY, wp_slash( $transactions ) ); } private static function active_key( int $post_id ): string { return self::ACTIVE_PREFIX . get_current_user_id() . '_' . max( 0, $post_id ); } private static function active_record( int $post_id ): array { if ( ! $post_id ) { return array(); } $key = self::active_key( $post_id ); $record = get_option( $key, array() ); if ( ! is_array( $record ) || empty( $record['transaction_id'] ) ) { return array(); } if ( empty( $record['updated_at'] ) || time() - intval( $record['updated_at'] ) > self::TTL ) { delete_option( $key ); self::add_tombstone( array( 'id' => (string) $record['transaction_id'], 'post_id' => $post_id, 'transaction_revision' => (string) ( $record['transaction_revision'] ?? '' ), 'semantic_version' => (string) ( $record['semantic_version'] ?? '' ), 'representation_version' => (string) ( $record['representation_version'] ?? '' ), ), 'expired' ); return array(); } return $record; } private static function acquire_active( int $post_id, string $transaction_id ): bool { $existing = self::active_record( $post_id ); if ( $existing ) { return hash_equals( (string) $existing['transaction_id'], $transaction_id ); } return (bool) add_option( self::active_key( $post_id ), array( 'transaction_id' => $transaction_id, 'post_id' => $post_id, 'created_at' => time(), 'updated_at' => time(), ), '', false ); } private static function touch_active( int $post_id, string $transaction_id ): void { $record = self::active_record( $post_id ); if ( $record && hash_equals( (string) $record['transaction_id'], $transaction_id ) ) { $record['updated_at'] = time(); $transaction = self::get_without_cleanup( $transaction_id ); $record['transaction_revision'] = (string) ( $transaction['transaction_revision'] ?? $record['transaction_revision'] ?? '' ); $record['semantic_version'] = (string) ( $transaction['semantic_version'] ?? $record['semantic_version'] ?? '' ); $record['representation_version'] = (string) ( $transaction['representation_version'] ?? $record['representation_version'] ?? '' ); update_option( self::active_key( $post_id ), $record, false ); } } private static function release_active( int $post_id, string $transaction_id ): void { if ( ! $post_id || ! $transaction_id ) { return; } $key = self::active_key( $post_id ); $record = get_option( $key, array() ); if ( is_array( $record ) && hash_equals( (string) ( $record['transaction_id'] ?? '' ), $transaction_id ) ) { delete_option( $key ); } } private static function active_error( array $active ): WP_Error { return new WP_Error( 'active_transaction_exists', __( 'Another Gutenberg transaction is already active for this post. Continue, commit, or discard that exact transaction; do not start a second mutation.', 'advanced-animation' ), self::recovery_data( array( 'id' => (string) ( $active['transaction_id'] ?? '' ), 'transaction_revision' => (string) ( $active['transaction_revision'] ?? '' ), 'semantic_version' => (string) ( $active['semantic_version'] ?? '' ), 'representation_version' => (string) ( $active['representation_version'] ?? '' ), ), false, array( 'inspect', 'commit', 'discard' ), true ) ); } private static function get_without_cleanup( string $transaction_id ): array { $transactions = get_user_meta( get_current_user_id(), self::META_KEY, true ); return is_array( $transactions ) && isset( $transactions[ $transaction_id ] ) ? (array) $transactions[ $transaction_id ] : array(); } private static function staged_transaction_for_post( int $post_id ): array { foreach ( self::transactions() as $transaction ) { if ( intval( $transaction['post_id'] ?? 0 ) === $post_id && 'staged' === ( $transaction['status'] ?? '' ) ) { return (array) $transaction; } } return array(); } private static function tombstones(): array { $user_id = get_current_user_id(); $tombstones = get_user_meta( $user_id, self::TOMBSTONE_KEY, true ); $tombstones = is_array( $tombstones ) ? $tombstones : array(); $changed = false; foreach ( $tombstones as $id => $tombstone ) { if ( empty( $tombstone['time'] ) || time() - intval( $tombstone['time'] ) > self::TOMBSTONE_TTL ) { unset( $tombstones[ $id ] ); $changed = true; } } if ( $changed ) { update_user_meta( $user_id, self::TOMBSTONE_KEY, $tombstones ); } return $tombstones; } private static function add_tombstone( array $transaction, string $reason ): void { $transaction_id = (string) ( $transaction['id'] ?? $transaction['transaction_id'] ?? '' ); if ( '' === $transaction_id ) { return; } $tombstones = self::tombstones(); $tombstones[ $transaction_id ] = array( 'id' => $transaction_id, 'post_id' => intval( $transaction['post_id'] ?? 0 ), 'transaction_revision' => (string) ( $transaction['transaction_revision'] ?? '' ), 'semantic_version' => (string) ( $transaction['semantic_version'] ?? '' ), 'representation_version' => (string) ( $transaction['representation_version'] ?? '' ), 'persisted_semantic_version' => (string) ( $transaction['persisted_semantic_version'] ?? '' ), 'persisted_representation_version' => (string) ( $transaction['persisted_representation_version'] ?? '' ), 'editor_delivery' => sanitize_key( $transaction['editor_delivery'] ?? '' ), 'terminal_reason' => sanitize_key( $transaction['terminal_reason'] ?? $reason ), 'reason' => $reason, 'time' => time(), ); update_user_meta( get_current_user_id(), self::TOMBSTONE_KEY, $tombstones ); } }