id = 'albert/update-post'; $this->label = __( 'Update Post', 'albert-ai-butler' ); $this->description = __( 'Update an existing WordPress post with new title, content, and metadata.', 'albert-ai-butler' ); $this->category = 'content'; $this->group = 'posts'; $this->input_schema = $this->get_input_schema(); $this->output_schema = $this->get_output_schema(); $this->meta = [ 'mcp' => [ 'public' => true, ], 'annotations' => Annotations::update( 'This replaces the whole post body. Read the post first with `albert/view-post` and ' . '`"format": ["blocks"]`, edit that tree, and send it back, writing without reading ' . 'silently discards everything you did not include. Keep every existing image block\'s `id` ' . 'and `url` exactly as they were unless you were asked to change the image. To change one ' . 'block, use `albert/edit-post-block` instead: it is cheaper and leaves every other block ' . 'byte-for-byte intact.' ), ]; parent::__construct(); } /** * Get the input schema for this ability. * * @return array Input schema. * @since 1.0.0 */ protected function get_input_schema(): array { // Get all available post statuses dynamically. $post_statuses = array_keys( get_post_statuses() ); return [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', 'description' => 'The post ID to update', ], 'title' => [ 'type' => 'string', 'description' => 'The post title', ], 'content' => [ 'type' => 'string', 'description' => 'The post content as a string. Accepts WordPress block markup (), HTML, or Markdown — converted to blocks automatically. For multi-block, nested layouts prefer the structured "blocks" field instead. Ignored when "blocks" is provided. Replaces the entire post body when set.', ], 'blocks' => [ 'type' => 'array', 'description' => 'Structured block specs (preferred over "content"). Replaces the entire post body. Each item is { "name": "core/paragraph", "attributes": { ... }, "innerBlocks": [ ... ] }; innerBlocks is the same shape recursively for layout blocks (e.g. core/columns > core/column). Put text in attributes (content/text/value) or in "plaintext". Example: [ { "name": "core/heading", "attributes": { "level": 2, "content": "Intro" } }, { "name": "core/paragraph", "attributes": { "content": "Hello world." } } ]', 'items' => BlockSpecSchema::spec(), ], 'status' => [ 'type' => 'string', 'enum' => $post_statuses, 'description' => 'Post status', ], 'excerpt' => [ 'type' => 'string', 'description' => 'Optional post excerpt', ], 'categories' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'description' => 'Array of category IDs', ], 'tags' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'description' => 'Array of tag names', ], ], 'required' => [ 'id' ], ]; } /** * Get the output schema for this ability. * * @return array Output schema. * @since 1.0.0 */ protected function get_output_schema(): array { return [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer' ], 'title' => [ 'type' => 'string' ], 'status' => [ 'type' => 'string' ], 'permalink' => [ 'type' => 'string' ], 'edit_url' => [ 'type' => 'string' ], 'block_issues' => [ 'type' => 'array', 'description' => 'Optional, non-fatal block validation warnings (the post was still saved). Each is an actionable message such as "content[0].attributes.url is required for core/image". Fatal block problems are not returned here — they come back as a WP_Error with code "block_validation_failed" and the post is not updated.', 'items' => [ 'type' => 'string' ], ], ], 'required' => [ 'id', 'title', 'status' ], ]; } /** * Check if current user has permission to execute this ability. * * Uses the permission callback from the WordPress REST API endpoint. * * @return bool|WP_Error True if permitted, WP_Error with details otherwise. * @since 1.0.0 */ public function check_permission(): bool|WP_Error { return $this->check_rest_permission( '/wp/v2/posts/(?P[\\d]+)', 'POST', 'edit_posts' ); } /** * Execute the ability - update a post using WordPress REST API. * * @param array $args { * Input parameters. * * @type int $id Post ID (required). * @type string $title Post title. * @type string $content Post content. * @type string $status Post status. * @type string $excerpt Post excerpt. * @type array $categories Category IDs. * @type array $tags Tag names. * } * @return array|WP_Error Post data on success, WP_Error on failure. * @since 1.0.0 */ public function execute( array $args ): array|WP_Error { $post_id = absint( $args['id'] ); // Check if post exists using REST API. $check_request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_id ); $check_response = rest_do_request( $check_request ); if ( $check_response->is_error() ) { return new WP_Error( 'post_not_found', __( 'Post not found.', 'albert-ai-butler' ), [ 'status' => 404 ] ); } // Prepare REST API request data (only include provided fields). $request_data = []; $block_issues = []; if ( isset( $args['title'] ) ) { $request_data['title'] = sanitize_text_field( $args['title'] ); } // Content is only touched when structured "blocks" or a "content" string // is provided; otherwise the existing post body is left untouched. $has_blocks_input = ! empty( $args['blocks'] ) && is_array( $args['blocks'] ); $has_content_input = $has_blocks_input || isset( $args['content'] ); if ( $has_content_input ) { // Resolve the content to store (classic rejection, block // serialization, allowed-block enforcement with the Update exemption, // and any block issues). $resolved = ( new WriteContentResolver() )->resolve( $args, 'post', $post_id ); if ( is_wp_error( $resolved ) ) { return $resolved; } $request_data['content'] = $resolved['content']; $block_issues = $resolved['block_issues']; } if ( isset( $args['status'] ) ) { $request_data['status'] = sanitize_key( $args['status'] ); } if ( isset( $args['excerpt'] ) ) { $request_data['excerpt'] = sanitize_textarea_field( $args['excerpt'] ); } // Add categories if provided. if ( ! empty( $args['categories'] ) && is_array( $args['categories'] ) ) { $request_data['categories'] = array_map( 'absint', $args['categories'] ); } // Add tags if provided (convert tag names to IDs using REST API). if ( ! empty( $args['tags'] ) && is_array( $args['tags'] ) ) { $tag_ids = $this->get_or_create_tag_ids( $args['tags'] ); if ( ! empty( $tag_ids ) ) { $request_data['tags'] = $tag_ids; } } // Create REST request. $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . $post_id ); foreach ( $request_data as $key => $value ) { $request->set_param( $key, $value ); } // Execute the request. $response = rest_do_request( $request ); $server = rest_get_server(); $data = $server->response_to_data( $response, false ); // Check for errors. if ( is_wp_error( $data ) ) { return $data; } if ( $response->is_error() ) { return new WP_Error( $data['code'] ?? 'rest_error', $data['message'] ?? __( 'An error occurred while updating the post.', 'albert-ai-butler' ), [ 'status' => $response->get_status() ] ); } // Return formatted post data. $post_id = $data['id']; $result = [ 'id' => $post_id, 'title' => $data['title']['rendered'] ?? '', 'status' => $data['status'], 'permalink' => $data['link'] ?? '', 'edit_url' => admin_url( 'post.php?post=' . $post_id . '&action=edit' ), ]; if ( ! empty( $block_issues ) ) { $result['block_issues'] = $block_issues; } return $result; } /** * Get or create tag IDs from tag names using REST API. * * @param array $tag_names Array of tag names. * @return array Array of tag IDs. * @since 1.0.0 */ private function get_or_create_tag_ids( array $tag_names ): array { $tag_ids = []; foreach ( $tag_names as $tag_name ) { $tag_name = sanitize_text_field( $tag_name ); // Search for existing tag using REST API. $search_request = new WP_REST_Request( 'GET', '/wp/v2/tags' ); $search_request->set_param( 'search', $tag_name ); $search_request->set_param( 'per_page', 1 ); $search_response = rest_do_request( $search_request ); $server = rest_get_server(); $search_data = $server->response_to_data( $search_response, false ); // Check if exact match exists. if ( ! is_wp_error( $search_data ) && ! empty( $search_data ) ) { foreach ( $search_data as $tag ) { if ( strtolower( $tag['name'] ) === strtolower( $tag_name ) ) { $tag_ids[] = $tag['id']; continue 2; } } } // Create new tag using REST API if not found. $create_request = new WP_REST_Request( 'POST', '/wp/v2/tags' ); $create_request->set_param( 'name', $tag_name ); $create_response = rest_do_request( $create_request ); $create_data = $server->response_to_data( $create_response, false ); if ( ! is_wp_error( $create_data ) && ! empty( $create_data['id'] ) ) { $tag_ids[] = $create_data['id']; } } return $tag_ids; } }