array( 'field' => 'post_title', 'type' => 'string', ), 'excerpt.raw' => array( 'field' => 'post_excerpt', 'type' => 'string', ), 'slug' => array( 'field' => 'post_name', 'type' => 'string', ), 'status' => array( 'field' => 'post_status', 'type' => 'string', ), 'parent' => array( 'field' => 'post_parent', 'type' => 'integer', ), 'menu_order' => array( 'field' => 'menu_order', 'type' => 'integer', ), 'comment_status' => array( 'field' => 'comment_status', 'type' => 'string', ), 'ping_status' => array( 'field' => 'ping_status', 'type' => 'string', ), 'content.raw' => array( 'field' => 'post_content', 'type' => 'string', ), ); private const STATUSES = array( 'draft', 'pending', 'private', 'publish', 'future', 'trash' ); /** * The complete set of values fields wp.posts.create accepts; unknown * fields fail in both the execute handler and reference-shape preflight. */ public const CREATE_FIELDS = array( 'post_type', 'title', 'excerpt', 'content', 'meta', 'acf', 'jetengine', 'terms', 'status', 'slug', 'parent', 'menu_order', 'comment_status', 'ping_status' ); private const DISCUSSION_STATUSES = array( 'open', 'closed' ); /** * Create a handler for one posts write capability. */ public function __construct( private string $capability ) { } /** * Run the shared validate/execute pipeline for one posts write command. * * @param array $input Command input document. * @param string $phase Either `validate` or `execute`. * @return array Internal result: `['data' => array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { return match ( $this->capability ) { self::CREATE_NAME => $this->create( $input, $phase ), self::UPDATE_NAME => $this->update( $input, $phase ), self::DELETE_NAME => $this->delete( $input, $phase ), default => $this->error( 'unsupported_capability', 'This posts write capability is not supported.' ), }; } /** * Validate and optionally create a post document. */ private function create( array $input, string $phase ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values ) { return $this->error( 'invalid_command', 'wp.posts.create requires a values object.', array( 'input', 'values' ) ); } $post_type = isset( $values['post_type'] ) && is_scalar( $values['post_type'] ) ? sanitize_key( (string) $values['post_type'] ) : ''; if ( '' === $post_type || ! in_array( $post_type, $this->registered_post_types(), true ) ) { return $this->error( 'invalid_value', 'values.post_type must be a registered post type.', array( 'input', 'values', 'post_type' ) ); } if ( ! isset( $values['title'] ) || ! is_array( $values['title'] ) || ! isset( $values['title']['raw'] ) || ! is_string( $values['title']['raw'] ) || '' === $values['title']['raw'] ) { return $this->error( 'invalid_value', 'wp.posts.create requires values.title.raw.', array( 'input', 'values', 'title', 'raw' ) ); } $create_capability = $this->post_type_create_capability( $post_type ); if ( ! current_user_can( $create_capability ) ) { return $this->error( 'permission_denied', 'The current user cannot create posts.', array( 'input', 'values', 'post_type' ), array( 'layer' => 'wp_capability', 'capability' => $create_capability, ) ); } foreach ( array_keys( $values ) as $field ) { if ( ! in_array( (string) $field, self::CREATE_FIELDS, true ) ) { return $this->error( 'invalid_path', 'values contains a field wp.posts.create does not accept.', array( 'input', 'values', (string) $field ) ); } } $document = array( 'post_type' => $post_type, 'title' => array( 'raw' => (string) $values['title']['raw'] ), ); foreach ( array( 'excerpt', 'content', 'meta', 'acf', 'jetengine', 'terms' ) as $field ) { if ( array_key_exists( $field, $values ) ) { $document[ $field ] = $values[ $field ]; } } foreach ( array( 'status', 'slug', 'parent', 'menu_order', 'comment_status', 'ping_status' ) as $field ) { if ( array_key_exists( $field, $values ) ) { $document[ $field ] = $values[ $field ]; } } $validation = $this->validate_create_document( $document, $post_type ); if ( isset( $validation['error'] ) ) { return $validation; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $postarr = array( 'post_type' => $post_type, 'post_title' => (string) $document['title']['raw'], 'post_status' => isset( $document['status'] ) ? (string) $document['status'] : 'draft', ); foreach ( self::CORE_PATHS as $path_key => $definition ) { if ( in_array( $path_key, array( 'title.raw', 'status' ), true ) ) { continue; } $value = $this->document_path_get( $document, explode( '.', $path_key ) ); if ( null !== $value ) { $postarr[ $definition['field'] ] = $value; } } $post_id = wp_insert_post( $postarr, true ); if ( ! is_numeric( $post_id ) || (int) $post_id <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to create the post.' ); } if ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) { foreach ( $document['meta'] as $key => $value ) { update_post_meta( (int) $post_id, (string) $key, $value ); } } if ( isset( $document['acf'] ) && is_array( $document['acf'] ) ) { $acf_update = $this->persist_acf_changes( (int) $post_id, array( 'acf' => array() ), $document, $post_type ); if ( isset( $acf_update['error'] ) ) { return $acf_update; } } if ( isset( $document['jetengine'] ) && is_array( $document['jetengine'] ) ) { $jetengine_update = $this->persist_jetengine_changes( (int) $post_id, array( 'jetengine' => array() ), $document, $post_type ); if ( isset( $jetengine_update['error'] ) ) { return $jetengine_update; } } if ( isset( $document['terms'] ) && is_array( $document['terms'] ) ) { $terms_update = $this->persist_terms_changes( (int) $post_id, array( 'terms' => array() ), $document, $post_type ); if ( isset( $terms_update['error'] ) ) { return $terms_update; } } return array( 'data' => array( 'created' => true, 'target' => $this->target_data( (int) $post_id, $post_type ), ), ); } /** * Validate and optionally update a post document. */ private function update( array $input, string $phase ): array { $prepared = $this->prepare_existing_target( $input, 'edit_post' ); if ( isset( $prepared['error'] ) ) { return $prepared; } $post = $prepared['post']; $target = $prepared['target']; $post_id = (int) $post->ID; $post_type = (string) $post->post_type; $precondition = $this->validate_preconditions( $post, $target ); if ( isset( $precondition['error'] ) ) { return $precondition; } $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : null; if ( null === $changes || array() === $changes ) { return $this->error( 'invalid_command', 'wp.posts.update requires a non-empty changes list.', array( 'input', 'changes' ) ); } $original = $this->document( $post ); $document = $original; foreach ( $changes as $index => $change ) { $result = $this->apply_change_to_document( $document, is_array( $change ) ? $change : array(), $post_type, $index ); if ( isset( $result['error'] ) ) { return $result; } $document = $result['document']; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $revision_id = $this->maybe_save_revision( $post, $post_id, $input ); $core_update = $this->persist_core_changes( $post_id, $original, $document ); if ( isset( $core_update['error'] ) ) { return $core_update; } $terms_update = $this->persist_terms_changes( $post_id, $original, $document, $post_type ); if ( isset( $terms_update['error'] ) ) { return $terms_update; } $acf_update = $this->persist_acf_changes( $post_id, $original, $document, $post_type ); if ( isset( $acf_update['error'] ) ) { return $acf_update; } $jetengine_update = $this->persist_jetengine_changes( $post_id, $original, $document, $post_type ); if ( isset( $jetengine_update['error'] ) ) { return $jetengine_update; } $this->persist_meta_changes( $post_id, $original, $document ); $data = array( 'updated' => true, 'target' => $this->target_data( $post_id, $post_type ), ); $fresh_post = get_post( $post_id ); if ( is_object( $fresh_post ) ) { $data['facts'] = $this->facts( $fresh_post ); } if ( null !== $revision_id ) { $data['revision_id'] = $revision_id; } return array( 'data' => $data ); } /** * Save a revision when requested and supported by the current post state. */ private function maybe_save_revision( object $post, int $post_id, array $input ): ?int { if ( empty( $input['create_revision'] ) || 'publish' !== (string) $post->post_status || ! function_exists( 'wp_save_post_revision' ) ) { return null; } $saved_revision_id = wp_save_post_revision( $post_id ); if ( is_wp_error( $saved_revision_id ) || false === $saved_revision_id ) { return null; } return (int) $saved_revision_id; } /** * Persist changed core post fields. */ private function persist_core_changes( int $post_id, array $original, array $document ): array { $postarr = array( 'ID' => $post_id ); foreach ( self::CORE_PATHS as $path_key => $definition ) { $segments = explode( '.', $path_key ); $value = $this->document_path_get( $document, $segments ); if ( $value !== $this->document_path_get( $original, $segments ) ) { $postarr[ $definition['field'] ] = $value; } } if ( count( $postarr ) <= 1 ) { return array( 'data' => array( 'valid' => true ) ); } $updated = wp_update_post( $postarr, true ); if ( is_wp_error( $updated ) ) { if ( $this->is_conflict_wp_error( $updated ) ) { return $this->error( 'conflict', 'A post with that slug or unique identity already exists.' ); } $message = (string) $updated->get_error_message(); return $this->error( 'execution_failed', '' !== $message ? $message : 'WordPress failed to update the post.' ); } if ( ! is_numeric( $updated ) || (int) $updated <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to update the post.' ); } return array( 'data' => array( 'valid' => true ) ); } /** * Return whether a WordPress update error represents a collision. */ private function is_conflict_wp_error( $error ): bool { $code = is_callable( array( $error, 'get_error_code' ) ) ? (string) $error->get_error_code() : ''; if ( in_array( $code, array( 'post_exists', 'post_name_conflict', 'term_exists', 'duplicate_term_slug' ), true ) ) { return true; } return str_contains( $code, 'duplicate' ) || str_contains( $code, 'already_exists' ) || str_contains( $code, 'conflict' ); } /** * Persist changed registered meta fields. */ private function persist_meta_changes( int $post_id, array $original, array $document ): void { $original_meta = isset( $original['meta'] ) && is_array( $original['meta'] ) ? $original['meta'] : array(); $updated_meta = isset( $document['meta'] ) && is_array( $document['meta'] ) ? $document['meta'] : array(); foreach ( array_keys( $original_meta ) as $key ) { if ( ! array_key_exists( $key, $updated_meta ) ) { delete_post_meta( $post_id, (string) $key ); } } foreach ( $updated_meta as $key => $value ) { if ( ! array_key_exists( $key, $original_meta ) || $original_meta[ $key ] !== $value ) { if ( PageStyles::META_PAGE_CSS === (string) $key ) { PageStyles::update_post_page_css( $post_id, $value ); continue; } update_post_meta( $post_id, (string) $key, $value ); } } } /** * Persist changed ACF field values through the ACF value API. */ private function persist_acf_changes( int $post_id, array $original, array $document, string $post_type ): array { $original_acf = isset( $original['acf'] ) && is_array( $original['acf'] ) ? $original['acf'] : array(); $updated_acf = isset( $document['acf'] ) && is_array( $document['acf'] ) ? $document['acf'] : array(); foreach ( $updated_acf as $key => $value ) { if ( array_key_exists( $key, $original_acf ) && $original_acf[ $key ] === $value ) { continue; } $result = PostTypesWriteCapability::update_acf_field_value( $post_id, $post_type, (string) $key, $value ); if ( isset( $result['error'] ) ) { return $result; } } return array( 'data' => array( 'valid' => true ) ); } /** * Persist changed JetEngine field values through its post-meta storage. */ private function persist_jetengine_changes( int $post_id, array $original, array $document, string $post_type ): array { $original_fields = isset( $original['jetengine'] ) && is_array( $original['jetengine'] ) ? $original['jetengine'] : array(); $updated_fields = isset( $document['jetengine'] ) && is_array( $document['jetengine'] ) ? $document['jetengine'] : array(); foreach ( $updated_fields as $key => $value ) { if ( array_key_exists( $key, $original_fields ) && $original_fields[ $key ] === $value ) { continue; } $result = PostTypesWriteCapability::update_jetengine_field_value( $post_id, $post_type, (string) $key, $value ); if ( isset( $result['error'] ) ) { return $result; } } return array( 'data' => array( 'valid' => true ) ); } /** * Persist taxonomy assignments changed through terms. paths. */ private function persist_terms_changes( int $post_id, array $original, array $document, string $post_type ): array { $original_terms = isset( $original['terms'] ) && is_array( $original['terms'] ) ? $original['terms'] : array(); $updated_terms = isset( $document['terms'] ) && is_array( $document['terms'] ) ? $document['terms'] : array(); $taxonomies = array_unique( array_merge( array_keys( $original_terms ), array_keys( $updated_terms ) ) ); foreach ( $taxonomies as $taxonomy ) { $taxonomy = (string) $taxonomy; if ( ! in_array( $taxonomy, $this->registered_taxonomies( $post_type ), true ) ) { return $this->error( 'invalid_path', 'Taxonomy terms path is not registered for this post type.', array( 'input', 'changes' ) ); } $next = array_key_exists( $taxonomy, $updated_terms ) ? $this->normalize_term_ids( $updated_terms[ $taxonomy ] ) : array(); if ( $next === $this->normalize_term_ids( $original_terms[ $taxonomy ] ?? array() ) ) { continue; } if ( function_exists( 'wp_set_post_terms' ) ) { $result = wp_set_post_terms( $post_id, $next, $taxonomy, false ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { return $this->error( 'execution_failed', $result->get_error_message() ); } if ( false === $result ) { return $this->error( 'execution_failed', 'WordPress failed to assign post terms.' ); } } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate and optionally delete/trash a post. */ private function delete( array $input, string $phase ): array { $prepared = $this->prepare_existing_target( $input, 'delete_post' ); if ( isset( $prepared['error'] ) ) { return $prepared; } $post = $prepared['post']; $target = $prepared['target']; $post_id = (int) $post->ID; $precondition = $this->validate_preconditions( $post, $target ); if ( isset( $precondition['error'] ) ) { return $precondition; } $force = isset( $input['force'] ) && true === $input['force']; if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $deleted = $force ? wp_delete_post( $post_id, true ) : wp_trash_post( $post_id ); if ( false === $deleted || null === $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the post.' ); } return array( 'data' => array( 'deleted' => true, 'trashed' => ! $force, 'target' => $this->target_data( $post_id, (string) $post->post_type ), ), ); } /** * Validate target identity and permissions for an existing post. */ private function prepare_existing_target( array $input, string $capability ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || ! isset( $target['id'] ) || ! is_numeric( $target['id'] ) || (int) $target['id'] <= 0 ) { return $this->error( 'invalid_target', 'Posts writes require a target with a positive integer id.', array( 'input', 'target' ) ); } $post = get_post( (int) $target['id'] ); if ( null === $post ) { return $this->error( 'not_found', 'No post exists for the requested id.', array( 'input', 'target', 'id' ) ); } if ( isset( $target['post_type'] ) ) { $asserted = is_scalar( $target['post_type'] ) ? (string) $target['post_type'] : ''; if ( $asserted !== (string) $post->post_type ) { return $this->error( 'invalid_target', 'Target post_type assertion did not match the stored post.', array( 'input', 'target', 'post_type' ), array( 'expected' => $asserted, 'current' => (string) $post->post_type, ) ); } } if ( ! current_user_can( $capability, (int) $post->ID ) ) { return $this->error( 'permission_denied', 'The current user cannot mutate this post.', array( 'input', 'target', 'id' ), array( 'layer' => 'wp_capability' ) ); } return array( 'target' => $target, 'post' => $post, ); } /** * Apply one validated change to an in-memory document. */ private function apply_change_to_document( array $document, array $change, string $post_type, int $index ): array { $allowed_keys = array( 'op' => true, 'path' => true, 'value' => true, 'provider' => true, ); foreach ( $change as $key => $_ ) { if ( ! isset( $allowed_keys[ $key ] ) ) { return $this->error( 'invalid_command', 'Change items may contain only op, path, value, and provider.', array( 'input', 'changes', $index, $key ) ); } } $op = isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : ''; if ( ! in_array( $op, array( 'set', 'remove', 'insert' ), true ) ) { return $this->error( 'invalid_command', 'Change op must be set, remove, or insert.', array( 'input', 'changes', $index, 'op' ) ); } $path = isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( array() === $path || ! $this->is_path( $path ) ) { return $this->error( 'invalid_path', 'Change path must be a non-empty array-segment path.', array( 'input', 'changes', $index, 'path' ) ); } if ( in_array( $op, array( 'set', 'insert' ), true ) && ! array_key_exists( 'value', $change ) ) { return $this->error( 'invalid_value', 'set and insert changes require a value.', array( 'input', 'changes', $index, 'value' ) ); } if ( isset( $change['provider'] ) && ! is_scalar( $change['provider'] ) ) { return $this->error( 'invalid_command', 'provider must be a scalar disambiguator.', array( 'input', 'changes', $index, 'provider' ) ); } if ( 'blocks' === (string) $path[0] ) { return $this->apply_block_change_to_document( $document, $change, $op, $path, $index ); } $path_info = $this->path_info( $path, $post_type ); if ( isset( $path_info['error'] ) ) { $path_info['error']['path'] = array( 'input', 'changes', $index, 'path' ); return $path_info; } if ( isset( $change['provider'] ) && (string) $change['provider'] !== $path_info['provider'] ) { return $this->error( 'invalid_path', 'provider does not match the selected path.', array( 'input', 'changes', $index, 'provider' ), array( 'expected' => $path_info['provider'], 'current' => (string) $change['provider'], ) ); } if ( 'set' === $op ) { if ( 'acf' === $path_info['provider'] && isset( $path_info['field'] ) && is_array( $path_info['field'] ) ) { $valid = $this->validate_acf_value( $change['value'], $path_info['field'], array( 'input', 'changes', $index, 'value' ) ); } elseif ( 'jetengine' === $path_info['provider'] && isset( $path_info['field'] ) && is_array( $path_info['field'] ) ) { $valid = $this->validate_jetengine_value( $change['value'], $path_info['field'], array( 'input', 'changes', $index, 'value' ) ); } else { $valid = $this->validate_value_for_path( $change['value'], $path_info['type'], array( 'input', 'changes', $index, 'value' ) ); } if ( isset( $valid['error'] ) ) { return $valid; } $path_validation = $this->validate_core_set_value( $path, $change['value'], $post_type, array( 'input', 'changes', $index, 'value' ) ); if ( isset( $path_validation['error'] ) ) { return $path_validation; } return array( 'document' => $this->document_path_set( $document, $path, $change['value'] ) ); } if ( 'remove' === $op ) { if ( ! $path_info['removable'] ) { return $this->error( 'invalid_path', 'remove is supported only for meta keys and list elements in this slice.', array( 'input', 'changes', $index, 'path' ) ); } return array( 'document' => $this->document_path_remove( $document, $path ) ); } if ( ! $path_info['insertable'] ) { return $this->error( 'invalid_path', 'insert is supported only for ordered-list paths.', array( 'input', 'changes', $index, 'path' ) ); } $valid = $this->validate_value_for_path( $change['value'], $path_info['item_type'], array( 'input', 'changes', $index, 'value' ) ); if ( isset( $valid['error'] ) ) { return $valid; } return array( 'document' => $this->document_path_insert( $document, $path, $change['value'] ) ); } /** * Return schema facts for a writable path. */ private function path_info( array $path, string $post_type ): array { $key = implode( '.', array_map( 'strval', $path ) ); if ( isset( self::CORE_PATHS[ $key ] ) ) { return array( 'type' => self::CORE_PATHS[ $key ]['type'], 'provider' => 'core', 'removable' => false, 'insertable' => false, ); } if ( array( 'title' ) === $path || array( 'excerpt' ) === $path || array( 'content' ) === $path ) { return array( 'type' => 'object', 'provider' => 'core', 'removable' => false, 'insertable' => false, ); } if ( 'meta' === (string) $path[0] && isset( $path[1] ) && is_scalar( $path[1] ) ) { if ( PageStyles::META_PAGE_CSS === (string) $path[1] && 2 === count( $path ) ) { return array( 'type' => 'object', 'provider' => 'meta', 'removable' => true, 'insertable' => false, ); } $schema = $this->registered_meta_schema( $post_type, (string) $path[1] ); if ( null === $schema ) { return $this->error( 'invalid_path', 'Meta path is not registered for REST writes on this post type.' ); } $type = $this->meta_type( $schema ); if ( 2 === count( $path ) ) { return array( 'type' => $type, 'provider' => 'meta', 'removable' => true, 'insertable' => false, ); } if ( 'array' === $type && 3 === count( $path ) ) { return array( 'type' => 'array', 'item_type' => 'string', 'provider' => 'meta', 'removable' => true, 'insertable' => true, ); } } if ( 'acf' === (string) $path[0] && isset( $path[1] ) && is_scalar( $path[1] ) && 2 === count( $path ) ) { $field = PostTypesWriteCapability::acf_field_for_post_type( $post_type, (string) $path[1] ); if ( null === $field ) { return $this->error( 'invalid_path', 'ACF path is not registered for this post type.' ); } return array( 'type' => PostTypesWriteCapability::schema_type_for_acf_field( $field ), 'provider' => 'acf', 'removable' => false, 'insertable' => false, 'field' => $field, ); } if ( 'jetengine' === (string) $path[0] && isset( $path[1] ) && is_scalar( $path[1] ) && 2 === count( $path ) ) { $field = PostTypesWriteCapability::jetengine_field_for_post_type( $post_type, (string) $path[1] ); if ( null === $field ) { return $this->error( 'invalid_path', 'JetEngine path is not registered for this post type.' ); } return array( 'type' => PostTypesWriteCapability::schema_type_for_jetengine_field( $field ), 'provider' => 'jetengine', 'removable' => false, 'insertable' => false, 'field' => $field, ); } if ( 'terms' === (string) $path[0] && isset( $path[1] ) && is_scalar( $path[1] ) && 2 === count( $path ) ) { if ( ! in_array( (string) $path[1], $this->registered_taxonomies( $post_type ), true ) ) { return $this->error( 'invalid_path', 'Taxonomy terms path is not registered for this post type.' ); } return array( 'type' => 'array', 'provider' => 'terms', 'removable' => true, 'insertable' => false, ); } return $this->error( 'invalid_path', 'Path is not writable for wp.posts in this slice.' ); } /** * Validate a create document against writable and required paths. */ private function validate_create_document( array $document, string $post_type ): array { foreach ( array( 'status', 'slug' ) as $path ) { if ( isset( $document[ $path ] ) ) { $valid = $this->validate_value_for_path( $document[ $path ], 'string', array( 'input', 'values', $path ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } foreach ( array( 'parent', 'menu_order' ) as $path ) { if ( isset( $document[ $path ] ) ) { $valid = $this->validate_value_for_path( $document[ $path ], 'integer', array( 'input', 'values', $path ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } foreach ( array( 'title', 'excerpt', 'content' ) as $path ) { if ( isset( $document[ $path ] ) ) { $valid = $this->validate_value_for_path( $document[ $path ], 'object', array( 'input', 'values', $path ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } if ( isset( $document['status'] ) && ! in_array( (string) $document['status'], self::STATUSES, true ) ) { return $this->error( 'invalid_value', 'Post status is not allowed.', array( 'input', 'values', 'status' ) ); } foreach ( array( 'comment_status', 'ping_status' ) as $path ) { if ( isset( $document[ $path ] ) && ! in_array( (string) $document[ $path ], self::DISCUSSION_STATUSES, true ) ) { return $this->error( 'invalid_value', 'Discussion status must be open or closed.', array( 'input', 'values', $path ) ); } } if ( isset( $document['parent'] ) ) { $parent_validation = $this->validate_parent_value( (int) $document['parent'], $post_type, array( 'input', 'values', 'parent' ) ); if ( isset( $parent_validation['error'] ) ) { return $parent_validation; } } if ( isset( $document['meta'] ) ) { if ( ! is_array( $document['meta'] ) ) { return $this->error( 'invalid_value', 'values.meta must be an object.', array( 'input', 'values', 'meta' ) ); } foreach ( $document['meta'] as $key => $value ) { $schema = $this->registered_meta_schema( $post_type, (string) $key ); if ( null === $schema ) { return $this->error( 'invalid_path', 'Meta path is not registered for REST writes on this post type.', array( 'input', 'values', 'meta', (string) $key ) ); } $valid = $this->validate_value_for_path( $value, $this->meta_type( $schema ), array( 'input', 'values', 'meta', (string) $key ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } if ( isset( $document['acf'] ) ) { if ( ! is_array( $document['acf'] ) ) { return $this->error( 'invalid_value', 'values.acf must be an object.', array( 'input', 'values', 'acf' ) ); } foreach ( $document['acf'] as $key => $value ) { $field = PostTypesWriteCapability::acf_field_for_post_type( $post_type, (string) $key ); if ( null === $field ) { return $this->error( 'invalid_path', 'ACF path is not registered for this post type.', array( 'input', 'values', 'acf', (string) $key ) ); } $valid = $this->validate_acf_value( $value, $field, array( 'input', 'values', 'acf', (string) $key ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } if ( isset( $document['jetengine'] ) ) { if ( ! is_array( $document['jetengine'] ) ) { return $this->error( 'invalid_value', 'values.jetengine must be an object.', array( 'input', 'values', 'jetengine' ) ); } foreach ( $document['jetengine'] as $key => $value ) { $field = PostTypesWriteCapability::jetengine_field_for_post_type( $post_type, (string) $key ); if ( null === $field ) { return $this->error( 'invalid_path', 'JetEngine path is not registered for this post type.', array( 'input', 'values', 'jetengine', (string) $key ) ); } $valid = $this->validate_jetengine_value( $value, $field, array( 'input', 'values', 'jetengine', (string) $key ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } if ( isset( $document['terms'] ) ) { if ( ! is_array( $document['terms'] ) ) { return $this->error( 'invalid_value', 'values.terms must be an object.', array( 'input', 'values', 'terms' ) ); } foreach ( $document['terms'] as $taxonomy => $terms ) { if ( ! in_array( (string) $taxonomy, $this->registered_taxonomies( $post_type ), true ) ) { return $this->error( 'invalid_path', 'Taxonomy terms path is not registered for this post type.', array( 'input', 'values', 'terms', (string) $taxonomy ) ); } $valid = $this->validate_value_for_path( $terms, 'array', array( 'input', 'values', 'terms', (string) $taxonomy ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate optimistic concurrency facts against the current post. */ private function validate_preconditions( object $post, array $target ): array { if ( ! isset( $target['if'] ) ) { return array( 'data' => array( 'valid' => true ) ); } if ( ! is_array( $target['if'] ) ) { return $this->error( 'invalid_command', 'target.if must be an object.', array( 'input', 'target', 'if' ) ); } $current = $this->facts( $post ); foreach ( array( 'content_hash', 'modified_gmt', 'revision_id' ) as $field ) { if ( array_key_exists( $field, $target['if'] ) && $target['if'][ $field ] !== $current[ $field ] ) { return $this->error( 'precondition_failed', 'Target precondition did not match the current post state.', array( 'input', 'target', 'if', $field ), array( 'expected' => array( $field => $target['if'][ $field ] ), 'current' => array( $field => $current[ $field ] ), ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Build the optimistic-concurrency facts for a post. */ private function facts( object $post ): array { return array( 'content_hash' => PostsGetCapability::content_hash( (string) $post->post_content ), 'modified_gmt' => (string) $post->post_modified_gmt, 'revision_id' => PostsGetCapability::latest_revision_id( (int) $post->ID ), ); } /** * Build a path-addressable document from a post. */ private function document( object $post ): array { return array( 'title' => array( 'raw' => (string) $post->post_title ), 'excerpt' => array( 'raw' => (string) $post->post_excerpt ), 'slug' => (string) $post->post_name, 'status' => (string) $post->post_status, 'parent' => (int) ( $post->post_parent ?? 0 ), 'menu_order' => (int) ( $post->menu_order ?? 0 ), 'comment_status' => (string) ( $post->comment_status ?? 'open' ), 'ping_status' => (string) ( $post->ping_status ?? 'open' ), 'content' => array( 'raw' => (string) $post->post_content ), 'blocks' => $this->parse_blocks_document( (string) $post->post_content ), 'meta' => $this->meta( (int) $post->ID ), 'acf' => PostTypesWriteCapability::acf_values_for_post( (int) $post->ID, (string) $post->post_type ), 'jetengine' => PostTypesWriteCapability::jetengine_values_for_post( (int) $post->ID, (string) $post->post_type ), 'terms' => $this->terms( (int) $post->ID, (string) $post->post_type ), ); } /** * Apply one block-path change and refresh content.raw from the parsed tree. */ private function apply_block_change_to_document( array $document, array $change, string $op, array $path, int $index ): array { if ( isset( $change['provider'] ) && 'core' !== (string) $change['provider'] ) { return $this->error( 'invalid_path', 'provider does not match the selected path.', array( 'input', 'changes', $index, 'provider' ), array( 'expected' => 'core', 'current' => (string) $change['provider'], ) ); } $path_info = $this->block_path_info( $document, $path, $op ); if ( isset( $path_info['error'] ) ) { $path_info['error']['path'] = array( 'input', 'changes', $index, 'path' ); return $path_info; } if ( 'attrs' === $path_info['kind'] ) { return $this->apply_block_attrs_change_to_document( $document, $change, $op, $path, $index ); } if ( 'remove' === $op ) { $document = $this->remove_block_node_from_document( $document, $path ); return $this->refresh_content_from_blocks( $document ); } $parsed = $this->parse_single_block_value( $change['value'], array( 'input', 'changes', $index, 'value' ) ); if ( isset( $parsed['error'] ) ) { return $parsed; } $block = $parsed['block']; if ( 'set' === $op ) { $document = $this->document_path_set( $document, $path, $block ); return $this->refresh_content_from_blocks( $document ); } if ( 'insert' === $op ) { $document = $this->insert_block_node_into_document( $document, $path, $block ); return $this->refresh_content_from_blocks( $document ); } return $this->error( 'invalid_command', 'Change op must be set, remove, or insert.', array( 'input', 'changes', $index, 'op' ) ); } /** * Apply a structured block attrs path update. */ private function apply_block_attrs_change_to_document( array $document, array $change, string $op, array $path, int $index ): array { if ( 'set' !== $op ) { return $this->error( 'invalid_path', 'Only set is supported for block attrs sub-paths.', array( 'input', 'changes', $index, 'path' ) ); } $document = $this->document_path_set( $document, $path, $change['value'] ); return $this->refresh_content_from_blocks( $document ); } /** * Insert a parsed block and keep parent innerContent placeholders aligned. */ private function insert_block_node_into_document( array $document, array $path, array $block ): array { $insert_index = $this->block_insert_index( $document, $path ); $document = $this->document_path_insert( $document, $path, $block ); if ( null === $insert_index ) { return $document; } return $this->sync_parent_inner_content_placeholders( $document, $path, $insert_index, 'insert' ); } /** * Remove a parsed block and keep parent innerContent placeholders aligned. */ private function remove_block_node_from_document( array $document, array $path ): array { $remove_index = $this->block_existing_index( $path ); $document = $this->document_path_remove( $document, $path ); if ( null === $remove_index ) { return $document; } return $this->sync_parent_inner_content_placeholders( $document, $path, $remove_index, 'remove' ); } /** * Return what kind of block path is addressed by this change. */ private function block_path_info( array $document, array $path, string $op ): array { if ( count( $path ) < 2 ) { return $this->error( 'invalid_path', 'Block paths must address a block node or attrs sub-path.' ); } $block_list = isset( $document['blocks'] ) && is_array( $document['blocks'] ) ? $document['blocks'] : array(); $pos = 1; while ( true ) { if ( ! array_key_exists( $pos, $path ) ) { return $this->error( 'invalid_path', 'Block paths must include a list position.' ); } $segment = $path[ $pos ]; if ( 'insert' === $op && count( $path ) - 1 === $pos ) { return $this->valid_block_insert_position( $block_list, $segment ); } if ( ! $this->valid_existing_block_index( $block_list, $segment ) ) { return $this->error( 'invalid_path', 'Block path index does not exist.' ); } $block = $block_list[ (int) $segment ]; if ( count( $path ) - 1 === $pos ) { return array( 'kind' => 'node' ); } $next = (string) $path[ $pos + 1 ]; if ( 'attrs' === $next ) { if ( $pos + 2 >= count( $path ) ) { return $this->error( 'invalid_path', 'Block attrs paths must include an attribute key.' ); } return array( 'kind' => 'attrs' ); } if ( 'innerBlocks' !== $next ) { return $this->error( 'invalid_path', 'Only innerBlocks and attrs are addressable below block nodes.' ); } if ( $pos + 2 >= count( $path ) ) { return $this->error( 'invalid_path', 'innerBlocks paths must include a list position.' ); } $block_list = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(); $pos += 2; } } /** * Validate an existing block list index. */ private function valid_existing_block_index( array $block_list, $segment ): bool { if ( ! is_int( $segment ) && ! ( is_string( $segment ) && ctype_digit( $segment ) ) ) { return false; } $index = (int) $segment; return $index >= 0 && $index < count( $block_list ); } /** * Validate an insert position in a block list. */ private function valid_block_insert_position( array $block_list, $segment ): array { if ( '-' === (string) $segment ) { return array( 'kind' => 'insert' ); } if ( ! is_int( $segment ) && ! ( is_string( $segment ) && ctype_digit( $segment ) ) ) { return $this->error( 'invalid_path', 'Block insert paths must end with an index or "-".' ); } $index = (int) $segment; if ( $index < 0 || $index > count( $block_list ) ) { return $this->error( 'invalid_path', 'Block insert index is outside the addressed list.' ); } return array( 'kind' => 'insert' ); } /** * Return the normalized insertion index for a block insert path. */ private function block_insert_index( array $document, array $path ): ?int { $index = end( $path ); if ( '-' !== (string) $index ) { return is_numeric( $index ) ? (int) $index : null; } $list_path = array_slice( $path, 0, -1 ); $list = $this->document_path_get( $document, $list_path ); return is_array( $list ) ? count( $list ) : 0; } /** * Return the existing child index from a block node path. */ private function block_existing_index( array $path ): ?int { $index = end( $path ); return is_numeric( $index ) ? (int) $index : null; } /** * Update the private Gutenberg placeholders that serialize nested blocks. */ private function sync_parent_inner_content_placeholders( array $document, array $path, int $child_index, string $operation ): array { $parent_path = $this->block_parent_path( $path ); if ( null === $parent_path ) { return $document; } $parent = $this->document_path_get( $document, $parent_path ); if ( ! is_array( $parent ) || ! isset( $parent['innerContent'] ) || ! is_array( $parent['innerContent'] ) ) { return $document; } $parent['innerContent'] = $this->inner_content_with_placeholder_change( $parent['innerContent'], $child_index, $operation ); return $this->document_path_set( $document, $parent_path, $parent ); } /** * Return the path to the parent block for a nested block path, if any. */ private function block_parent_path( array $path ): ?array { if ( count( $path ) <= 2 ) { return null; } return array_slice( $path, 0, -2 ); } /** * Add or remove one innerContent null placeholder at a child index. */ private function inner_content_with_placeholder_change( array $inner_content, int $child_index, string $operation ): array { $positions = array(); foreach ( $inner_content as $index => $part ) { if ( null === $part ) { $positions[] = $index; } } if ( 'remove' === $operation ) { if ( array_key_exists( $child_index, $positions ) ) { array_splice( $inner_content, $positions[ $child_index ], 1 ); } return $inner_content; } if ( array_key_exists( $child_index, $positions ) ) { array_splice( $inner_content, $positions[ $child_index ], 0, array( null ) ); return $inner_content; } if ( count( $positions ) === $child_index && count( $positions ) > 0 ) { array_splice( $inner_content, $positions[ count( $positions ) - 1 ] + 1, 0, array( null ) ); return $inner_content; } if ( 0 === $child_index && array() === $positions ) { $inner_content[] = null; } return $inner_content; } /** * Parse the post content into a mutable block document. * * @return array> */ private function parse_blocks_document( string $content ): array { if ( ! function_exists( 'parse_blocks' ) ) { return array(); } $blocks = parse_blocks( $content ); return is_array( $blocks ) ? array_values( $blocks ) : array(); } /** * Parse and validate a serialized block-node value. */ private function parse_single_block_value( $value, array $path ): array { if ( ! is_string( $value ) || ! function_exists( 'parse_blocks' ) || ! function_exists( 'serialize_blocks' ) ) { return $this->error( 'invalid_value', 'Block node values must be serialized block markup strings.', $path ); } $blocks = parse_blocks( $value ); if ( ! is_array( $blocks ) || 1 !== count( $blocks ) || ! is_array( $blocks[0] ) || empty( $blocks[0]['blockName'] ) ) { return $this->error( 'invalid_value', 'Block node values must parse to exactly one well-formed block.', $path ); } if ( serialize_blocks( $blocks ) !== $value ) { return $this->error( 'invalid_value', 'Block node markup must round trip through the WordPress block parser.', $path ); } return array( 'block' => $blocks[0] ); } /** * Serialize the parsed block document back into content.raw. */ private function refresh_content_from_blocks( array $document ): array { if ( ! function_exists( 'serialize_blocks' ) ) { return $this->error( 'execution_failed', 'WordPress block serialization is unavailable.' ); } $blocks = isset( $document['blocks'] ) && is_array( $document['blocks'] ) ? array_values( $document['blocks'] ) : array(); $document['blocks'] = $blocks; $document['content']['raw'] = serialize_blocks( $blocks ); return array( 'document' => $document ); } /** * Read flattened post meta as a document namespace. */ private function meta( int $post_id ): array { $raw = get_post_meta( $post_id ); if ( ! is_array( $raw ) ) { return array(); } $meta = array(); foreach ( $raw as $key => $values ) { $meta[ (string) $key ] = is_array( $values ) && 1 === count( $values ) ? array_values( $values )[0] : $values; } return $meta; } /** * Read assigned term IDs by registered taxonomy. */ private function terms( int $post_id, string $post_type ): array { $terms = array(); foreach ( $this->registered_taxonomies( $post_type ) as $taxonomy ) { if ( function_exists( 'wp_get_post_terms' ) ) { $raw = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) ); $terms[ $taxonomy ] = is_array( $raw ) ? $this->normalize_term_ids( $raw ) : array(); } else { $terms[ $taxonomy ] = array(); } } return $terms; } /** * Normalize a terms value into unique integer IDs. * * @param mixed $value Raw terms value. * @return array */ private function normalize_term_ids( $value ): array { if ( ! is_array( $value ) ) { return array(); } $ids = array(); foreach ( $value as $item ) { $id = is_object( $item ) && isset( $item->term_id ) ? (int) $item->term_id : ( is_numeric( $item ) ? (int) $item : 0 ); if ( $id > 0 ) { $ids[] = $id; } } $ids = array_values( array_unique( $ids ) ); sort( $ids ); return $ids; } /** * Validate a value against an ACF field definition. */ private function validate_acf_value( $value, array $field, array $path ): array { $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : 'text'; if ( 'repeater' === $type ) { if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'ACF repeater values must be a list of row objects.', $path, array( 'expected_type' => 'array' ) ); } $sub_fields = $this->acf_sub_fields_by_name( $field['sub_fields'] ?? array() ); foreach ( $value as $row_index => $row ) { if ( ! is_array( $row ) || array_values( $row ) === $row ) { return $this->error( 'invalid_value', 'ACF repeater rows must be objects keyed by subfield name.', array_merge( $path, array( $row_index ) ) ); } foreach ( $row as $sub_name => $sub_value ) { if ( ! isset( $sub_fields[ (string) $sub_name ] ) ) { return $this->error( 'invalid_path', 'ACF repeater row contains an unknown subfield.', array_merge( $path, array( $row_index, (string) $sub_name ) ) ); } $valid = $this->validate_acf_value( $sub_value, $sub_fields[ (string) $sub_name ], array_merge( $path, array( $row_index, (string) $sub_name ) ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } return array( 'data' => array( 'valid' => true ) ); } if ( 'gallery' === $type ) { if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'ACF gallery values must be a list of attachment IDs.', $path, array( 'expected_type' => 'array' ) ); } foreach ( $value as $index => $item ) { if ( ! is_int( $item ) || $item < 1 ) { return $this->error( 'invalid_value', 'ACF gallery items must be positive attachment IDs.', array_merge( $path, array( $index ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } if ( 'image' === $type ) { return $this->validate_value_for_path( $value, 'integer', $path ); } if ( 'checkbox' === $type ) { return $this->validate_value_for_path( $value, 'array', $path ); } return $this->validate_value_for_path( $value, PostTypesWriteCapability::schema_type_for_acf_field( $field ), $path ); } /** * Validate a value against a JetEngine field definition. */ private function validate_jetengine_value( $value, array $field, array $path ): array { $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : 'text'; if ( 'repeater' !== $type ) { return $this->validate_value_for_path( $value, PostTypesWriteCapability::schema_type_for_jetengine_field( $field ), $path ); } if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'JetEngine repeater values must be a list of row objects.', $path, array( 'expected_type' => 'array' ) ); } $sub_fields = $this->jetengine_sub_fields_by_name( $field['repeater-fields'] ?? array() ); foreach ( $value as $row_index => $row ) { if ( ! is_array( $row ) || array_values( $row ) === $row ) { return $this->error( 'invalid_value', 'JetEngine repeater rows must be objects keyed by subfield name.', array_merge( $path, array( $row_index ) ) ); } foreach ( $row as $sub_name => $sub_value ) { if ( ! isset( $sub_fields[ (string) $sub_name ] ) ) { return $this->error( 'invalid_path', 'JetEngine repeater row contains an unknown subfield.', array_merge( $path, array( $row_index, (string) $sub_name ) ) ); } $valid = $this->validate_jetengine_value( $sub_value, $sub_fields[ (string) $sub_name ], array_merge( $path, array( $row_index, (string) $sub_name ) ) ); if ( isset( $valid['error'] ) ) { return $valid; } } } return array( 'data' => array( 'valid' => true ) ); } /** * Return ACF subfields by name. */ private function acf_sub_fields_by_name( $fields ): array { return $this->fields_by_name( $fields ); } /** * Return JetEngine subfields by name. */ private function jetengine_sub_fields_by_name( $fields ): array { return $this->fields_by_name( $fields ); } /** * Return fields by name. */ private function fields_by_name( $fields ): array { if ( ! is_array( $fields ) ) { return array(); } $by_name = array(); foreach ( $fields as $field ) { if ( is_array( $field ) && isset( $field['name'] ) && is_scalar( $field['name'] ) ) { $by_name[ (string) $field['name'] ] = $field; } } return $by_name; } /** * Validate a value against a schema type. */ private function validate_value_for_path( $value, string $type, array $path ): array { $valid = match ( $type ) { 'string' => is_string( $value ), 'integer' => is_int( $value ), 'number' => is_int( $value ) || is_float( $value ), 'boolean' => is_bool( $value ), 'array' => is_array( $value ) && array_values( $value ) === $value, 'object' => is_array( $value ), default => true, }; if ( ! $valid ) { return $this->error( 'invalid_value', 'Value does not match the effective schema type.', $path, array( 'expected_type' => $type ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_core_set_value( array $path, $value, string $post_type, array $error_path ): array { $key = implode( '.', array_map( 'strval', $path ) ); if ( 'status' === $key && ! in_array( (string) $value, self::STATUSES, true ) ) { return $this->error( 'invalid_value', 'Post status is not allowed.', $error_path ); } if ( in_array( $key, array( 'comment_status', 'ping_status' ), true ) && ! in_array( (string) $value, self::DISCUSSION_STATUSES, true ) ) { return $this->error( 'invalid_value', 'Discussion status must be open or closed.', $error_path ); } if ( 'parent' === $key ) { return $this->validate_parent_value( (int) $value, $post_type, $error_path ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_parent_value( int $parent_id, string $post_type, array $path ): array { if ( $parent_id < 0 ) { return $this->error( 'invalid_value', 'parent must be a non-negative post id.', $path ); } if ( 0 === $parent_id ) { return array( 'data' => array( 'valid' => true ) ); } if ( ! $this->post_type_is_hierarchical( $post_type ) ) { return $this->error( 'invalid_value', 'parent is supported only for hierarchical post types.', $path ); } $parent = function_exists( 'get_post' ) ? get_post( $parent_id ) : null; if ( ! is_object( $parent ) ) { return $this->error( 'invalid_value', 'parent must reference an existing post.', $path ); } return array( 'data' => array( 'valid' => true ) ); } private function post_type_is_hierarchical( string $post_type ): bool { if ( function_exists( 'get_post_type_object' ) ) { $post_type_object = get_post_type_object( $post_type ); if ( is_object( $post_type_object ) && property_exists( $post_type_object, 'hierarchical' ) ) { return (bool) $post_type_object->hierarchical; } } return 'page' === $post_type; } /** * Return registered meta schema for one key. */ private function registered_meta_schema( string $post_type, string $key ): ?array { if ( ! function_exists( 'get_registered_meta_keys' ) ) { return null; } $registered = get_registered_meta_keys( 'post', $post_type ); if ( ! isset( $registered[ $key ] ) || ! is_array( $registered[ $key ] ) || empty( $registered[ $key ]['show_in_rest'] ) ) { return null; } return $registered[ $key ]; } /** * Normalize a registered meta type. */ private function meta_type( array $schema ): string { $type = isset( $schema['type'] ) && is_scalar( $schema['type'] ) ? (string) $schema['type'] : 'string'; return in_array( $type, array( 'string', 'integer', 'number', 'boolean', 'array', 'object' ), true ) ? $type : 'string'; } /** * Return registered post type names. * * @return array */ private function registered_post_types(): array { if ( ! function_exists( 'get_post_types' ) ) { return array( 'post', 'page' ); } return array_values( array_map( 'strval', get_post_types( array(), 'names' ) ) ); } /** * Return taxonomies attached to a post type. * * @return array */ private function registered_taxonomies( string $post_type ): array { if ( ! function_exists( 'get_object_taxonomies' ) ) { return array(); } $taxonomies = get_object_taxonomies( $post_type, 'names' ); return is_array( $taxonomies ) ? array_values( array_map( 'strval', $taxonomies ) ) : array(); } /** * Resolve the create capability for a post type. */ private function post_type_create_capability( string $post_type ): string { if ( function_exists( 'get_post_type_object' ) ) { $post_type_object = get_post_type_object( $post_type ); if ( is_object( $post_type_object ) && isset( $post_type_object->cap ) ) { return (string) ( $post_type_object->cap->create_posts ?? $post_type_object->cap->edit_posts ?? 'edit_posts' ); } } return 'page' === $post_type ? 'edit_pages' : 'edit_posts'; } /** * Return whether a value is a non-empty scalar path. */ private function is_path( array $path ): bool { foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } return true; } /** * Read a document path, returning null for a missing path. */ private function document_path_get( array $document, array $path ) { $node = $document; foreach ( $path as $segment ) { $key = (string) $segment; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return null; } $node = $node[ $key ]; } return $node; } /** * Set a document path. */ private function document_path_set( array $document, array $path, $value ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->document_path_set( $child, array_slice( $path, 1 ), $value ); return $document; } /** * Remove a document path. */ private function document_path_remove( array $document, array $path ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { unset( $document[ $key ] ); return $document; } if ( isset( $document[ $key ] ) && is_array( $document[ $key ] ) ) { $document[ $key ] = $this->document_path_remove( $document[ $key ], array_slice( $path, 1 ) ); if ( array_values( $document[ $key ] ) === $document[ $key ] ) { $document[ $key ] = array_values( $document[ $key ] ); } } return $document; } /** * Insert into an ordered-list document path. */ private function document_path_insert( array $document, array $path, $value ): array { $index_path = array_slice( $path, 0, -1 ); $index = end( $path ); $list = $this->document_path_get( $document, $index_path ); $list = is_array( $list ) ? array_values( $list ) : array(); if ( '-' === (string) $index ) { $list[] = $value; } else { array_splice( $list, max( 0, (int) $index ), 0, array( $value ) ); } return $this->document_path_set( $document, $index_path, $list ); } /** * Build result target identity. */ private function target_data( int $post_id, string $post_type ): array { return array( 'resource' => 'posts', 'id' => $post_id, 'post_type' => $post_type, ); } /** * Build a handler error. */ private function error( string $code, string $message, ?array $path = null, array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }