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 );
}
}