From 35e6f47d8b96cf83558b7fd7240adb3b82ddf21b Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 02:57:14 +0200 Subject: [PATCH 1/9] feat: MCP Abilities for headless content workflows Registers WordPress 6.9 Abilities API endpoints so AI tools (Claude Code, Cursor) and VS Code can manage WordPress content programmatically via MCP, by pairing this plugin with WordPress/mcp-adapter. This is the migrated content from the standalone spoko-mcp-abilities plugin (spokospace/spoko-mcp-abilities PR #1, now archived) - folded back into Enhanced because: - Enhanced already owns the spoko/v1 namespace and the Polylang integration code (PolylangSupport) - Admin Features Management settings page is the natural place for the on/off toggle - One plugin to install/maintain rather than two - Capability checks remain the real security boundary; a second plugin added no real isolation Eleven abilities under category spoko-content: - posts-create / posts-update / posts-delete - pages-create / pages-update / pages-delete - terms-create / terms-update / terms-delete (category + post_tag) - translations-link / translations-unlink (Polylang, gated) Per-ability: - JSON Schema input/output with top-level required arrays (standard form, validated by rest_validate_value_from_schema) - WordPress capability checks in permission_callback, status-aware on create + update (publish_*/edit_* depending on the requested status; ID-bound for edit/delete) - get_post_type() guard so spoko/pages-* cannot mutate posts (and vice versa) despite WPs map_meta_cap aliasing - term_exists(id, taxonomy) guard on terms-update/delete - Translations-link is additive: existing translation groups are merged in; multi-group input is rejected (409); language codes are validated against pll_languages_list(); minProperties=2 - MCP annotations (readonly/destructive/idempotent) New files: - src/Features/Mcp/AbilitiesFeature.php (entry, hook wiring, function_exists + admin toggle gating) - src/Features/Mcp/Posts.php - src/Features/Mcp/Pages.php - src/Features/Mcp/Terms.php - src/Features/Mcp/Translations.php Modified: - src/Core/Plugin.php - wires AbilitiesFeature in initFeatures() and registerGlobalFeatures() - src/Features/AdminInterface.php - new MCP Abilities row in the Features Management card; option in saveFeatureSettings - spoko-enhanced-rest-api.php - bump 1.1.0 -> 1.2.0, expand description - readme.txt / README.md - document the feature Requires WordPress 6.9 for MCP features; falls back to silent no-op on older WP versions. Default: enabled. Toggle: SPOKO REST API -> Features Management -> MCP Abilities. --- README.md | 21 +++ readme.txt | 27 +++- spoko-enhanced-rest-api.php | 4 +- src/Core/Plugin.php | 10 +- src/Features/AdminInterface.php | 24 ++- src/Features/Mcp/AbilitiesFeature.php | 55 +++++++ src/Features/Mcp/Pages.php | 207 ++++++++++++++++++++++++ src/Features/Mcp/Posts.php | 218 ++++++++++++++++++++++++++ src/Features/Mcp/Terms.php | 185 ++++++++++++++++++++++ src/Features/Mcp/Translations.php | 154 ++++++++++++++++++ 10 files changed, 895 insertions(+), 10 deletions(-) create mode 100644 src/Features/Mcp/AbilitiesFeature.php create mode 100644 src/Features/Mcp/Pages.php create mode 100644 src/Features/Mcp/Posts.php create mode 100644 src/Features/Mcp/Terms.php create mode 100644 src/Features/Mcp/Translations.php diff --git a/README.md b/README.md index 0fe2528..8a0a69d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,27 @@ Configurable via admin panel: - **Anonymous Comments** - Allow posting comments via REST API without authentication - **Comment Notifications** - Automatic email notifications to moderators when new comments are created via REST API +### MCP Abilities (Optional, requires WordPress 6.9+) +Registers WordPress 6.9 Abilities API endpoints for headless content workflows. Pair with [WordPress/mcp-adapter](https://github.com/WordPress/mcp-adapter) to expose them over MCP so AI tools (Claude Code, Cursor) and VS Code can manage content programmatically. + +Eleven abilities under category `spoko-content`: + +| Ability | Capability | Notes | +|---|---|---| +| `spoko/posts-create` | `publish_posts` for publish/private/future, else `edit_posts` | | +| `spoko/posts-update` | `edit_post` on the target ID | post_type guard; extra `publish_posts` check when transitioning to publish | +| `spoko/posts-delete` | `delete_post` on the target ID | post_type guard | +| `spoko/pages-create` | `publish_pages` for publish/private/future, else `edit_pages` | | +| `spoko/pages-update` | `edit_page` on the target ID | post_type guard; extra `publish_pages` check when transitioning to publish | +| `spoko/pages-delete` | `delete_page` on the target ID | post_type guard | +| `spoko/terms-create` | `manage_categories` / `manage_post_tags` | | +| `spoko/terms-update` | same | rejects when term ID is not in the given taxonomy | +| `spoko/terms-delete` | same | | +| `spoko/translations-link` | `edit_posts` + `edit_post` on every input ID | additive; rejects multi-group fusion; validates language codes against Polylang | +| `spoko/translations-unlink` | `edit_post` on the target ID | | + +Each ability validates input/output via JSON Schema, enforces WordPress capabilities in `permission_callback`, and carries MCP annotations (`readonly` / `destructive` / `idempotent`). Toggleable from **SPOKO REST API → Features Management → MCP Abilities**. Silently no-ops on WordPress < 6.9. + ### Headless Mode (Optional) Complete headless WordPress functionality: - **Frontend Redirect** - Automatically redirects all visitors to your headless frontend application diff --git a/readme.txt b/readme.txt index 2ef3f17..acd595b 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === SPOKO Enhanced WP REST API === Contributors: spoko -Tags: rest-api, headless, cms, api, polylang, multilingual, astro, nextjs +Tags: rest-api, headless, cms, api, polylang, multilingual, astro, nextjs, mcp, abilities-api, ai Requires at least: 5.0 -Tested up to: 6.7 +Tested up to: 6.9 Requires PHP: 8.3 -Stable tag: 1.0.8 +Stable tag: 1.2.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -82,6 +82,19 @@ Configurable via admin panel: * Anonymous Comments - Allow posting comments via REST API without authentication * Comment Notifications - Automatic email notifications to moderators when new comments are created via REST API +**MCP Abilities (Optional, requires WordPress 6.9+)** + +Registers WordPress 6.9 Abilities API endpoints for headless content workflows. Pair with [WordPress/mcp-adapter](https://github.com/WordPress/mcp-adapter) to expose them over MCP so AI tools (Claude Code, Cursor) and VS Code can manage content programmatically. + +Eleven abilities under category `spoko-content`: + +* `spoko/posts-create`, `posts-update`, `posts-delete` — CRUD on posts +* `spoko/pages-create`, `pages-update`, `pages-delete` — CRUD on pages +* `spoko/terms-create`, `terms-update`, `terms-delete` — categories and tags +* `spoko/translations-link`, `translations-unlink` — Polylang translation pairing (registered only when Polylang is active) + +Each ability validates input/output via JSON Schema, enforces WordPress capabilities in `permission_callback` (status-aware for posts/pages), and carries MCP annotations (`readonly` / `destructive` / `idempotent`). The feature is toggleable from the admin settings page and silently no-ops on WordPress < 6.9. + **Headless Mode (Optional)** Complete headless WordPress functionality: @@ -148,6 +161,14 @@ No, the plugin is optimized for performance and only adds minimal processing to == Changelog == += 1.2.0 = +* Added: MCP Abilities feature — eleven Abilities API endpoints (posts/pages/terms CRUD + Polylang translation pairing) for headless content workflows +* Added: Admin toggle "MCP Abilities" in Features Management +* Feature: Status-aware capability checks on post/page create and update +* Feature: Post-type guard prevents cross-post-type mutation +* Feature: Polylang translation linking is additive and rejects multi-group fusion +* Requires WordPress 6.9+ for MCP features (silently disabled on older versions); rest of plugin unchanged + = 1.0.8 = * Added: Headless Mode - Complete headless WordPress functionality * Added: Frontend redirect with URL path preservation diff --git a/spoko-enhanced-rest-api.php b/spoko-enhanced-rest-api.php index 13e3934..99fc8cc 100644 --- a/spoko-enhanced-rest-api.php +++ b/spoko-enhanced-rest-api.php @@ -1,8 +1,8 @@ cache), new CategoryFeaturedImage(), new PostCounters($this->logger), - new MenusEndpoint() + new MenusEndpoint(), + new AbilitiesFeature(), ]; } @@ -89,7 +91,7 @@ public function registerGlobalFeatures(): void foreach ($this->features as $feature) { // Only register HeadlessMode, AdminInterface and CategoryFeaturedImage here // Other features will be registered via their specific hooks - if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) { + if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) { if (method_exists($feature, 'register')) { $feature->register(); } @@ -101,7 +103,7 @@ public function registerRestFields(): void { foreach ($this->features as $feature) { // Skip features already registered globally - if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) { + if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) { continue; } @@ -127,7 +129,7 @@ public function registerAdminFeatures(): void { foreach ($this->features as $feature) { // Skip features already registered globally - if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) { + if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) { continue; } diff --git a/src/Features/AdminInterface.php b/src/Features/AdminInterface.php index 20e85d0..71beeef 100644 --- a/src/Features/AdminInterface.php +++ b/src/Features/AdminInterface.php @@ -4,6 +4,7 @@ namespace Spoko\EnhancedRestAPI\Features; +use Spoko\EnhancedRestAPI\Features\Mcp\AbilitiesFeature; use Spoko\EnhancedRestAPI\Services\TranslationCache; class AdminInterface @@ -152,7 +153,8 @@ private function saveFeatureSettings(): void 'spoko_rest_relative_urls_enabled', 'spoko_rest_anonymous_comments_enabled', 'spoko_rest_comment_notifications_enabled', - 'spoko_rest_post_counters_enabled' + 'spoko_rest_post_counters_enabled', + AbilitiesFeature::OPTION_ENABLED, ]; foreach ($features as $feature) { @@ -399,6 +401,26 @@ public function renderAdminPage(): void

+ + + MCP Abilities + + +

+ Exposes content CRUD (posts, pages, categories, tags) and Polylang translation pairing as + WordPress Abilities API + endpoints under category spoko-content. Pair with + mcp-adapter to expose them over MCP for Claude Code / Cursor / VS Code. + Requires WordPress 6.9+ — silently no-ops on older versions. +

+ + diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php new file mode 100644 index 0000000..acbb7a9 --- /dev/null +++ b/src/Features/Mcp/AbilitiesFeature.php @@ -0,0 +1,55 @@ + __('SPOKO content workflows', 'spoko-enhanced-rest-api'), + 'description' => __('Headless content management: posts/pages/terms CRUD and Polylang translation pairing.', 'spoko-enhanced-rest-api'), + ] + ); + } + + public function registerAbilities(): void + { + if (!function_exists('wp_register_ability')) { + return; + } + + Posts::register(); + Pages::register(); + Terms::register(); + + if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) { + Translations::register(); + } + } +} diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php new file mode 100644 index 0000000..c4bb6bc --- /dev/null +++ b/src/Features/Mcp/Pages.php @@ -0,0 +1,207 @@ + __('Create page', 'spoko-enhanced-rest-api'), + 'description' => __('Create a WordPress page with title, content, status, slug, and optional parent ID.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::createInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canCreate'], + 'execute_callback' => [self::class, 'create'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]], + ]); + + wp_register_ability('spoko/pages-update', [ + 'label' => __('Update page', 'spoko-enhanced-rest-api'), + 'description' => __('Update an existing page by ID. Only provided fields are changed.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::updateInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canEdit'], + 'execute_callback' => [self::class, 'update'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/pages-delete', [ + 'label' => __('Delete page', 'spoko-enhanced-rest-api'), + 'description' => __('Move a page to trash, or permanently delete with force=true.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::deleteInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canDelete'], + 'execute_callback' => [self::class, 'delete'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], + ]); + } + + public static function canCreate(array $input): bool|WP_Error + { + $status = (string) ($input['status'] ?? 'draft'); + $cap = in_array($status, self::PUBLISH_STATUSES, true) ? 'publish_pages' : 'edit_pages'; + + return current_user_can($cap) + ? true + : new WP_Error('forbidden', __('You cannot create pages with this status.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function canEdit(array $input): bool|WP_Error + { + $id = (int) ($input['id'] ?? 0); + if (get_post_type($id) !== 'page') { + return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + if (!current_user_can('edit_page', $id)) { + return new WP_Error('forbidden', __('You cannot edit this page.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + if (array_key_exists('status', $input) + && in_array((string) $input['status'], self::PUBLISH_STATUSES, true) + && !current_user_can('publish_pages') + ) { + return new WP_Error('forbidden', __('You cannot transition a page to a published state.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + return true; + } + + public static function canDelete(array $input): bool|WP_Error + { + $id = (int) ($input['id'] ?? 0); + if (get_post_type($id) !== 'page') { + return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + return current_user_can('delete_page', $id) + ? true + : new WP_Error('forbidden', __('You cannot delete this page.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function create(array $input): array|WP_Error + { + $args = [ + 'post_type' => 'page', + 'post_title' => (string) $input['title'], + 'post_content' => (string) ($input['content'] ?? ''), + 'post_status' => (string) ($input['status'] ?? 'draft'), + ]; + foreach (['slug' => 'post_name', 'excerpt' => 'post_excerpt'] as $in => $field) { + if (array_key_exists($in, $input)) { + $args[$field] = (string) $input[$in]; + } + } + if (!empty($input['parent_id'])) { + $args['post_parent'] = (int) $input['parent_id']; + } + + $id = wp_insert_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + return ['id' => (int) $id]; + } + + public static function update(array $input): array|WP_Error + { + $args = ['ID' => (int) $input['id']]; + + $map = [ + 'title' => 'post_title', + 'content' => 'post_content', + 'status' => 'post_status', + 'slug' => 'post_name', + 'excerpt' => 'post_excerpt', + ]; + foreach ($map as $in => $field) { + if (array_key_exists($in, $input)) { + $args[$field] = (string) $input[$in]; + } + } + if (array_key_exists('parent_id', $input)) { + $args['post_parent'] = (int) $input['parent_id']; + } + + $id = wp_update_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + return ['id' => (int) $id]; + } + + public static function delete(array $input): array|WP_Error + { + $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false)); + if (!$result) { + return new WP_Error('delete_failed', __('Could not delete page.', 'spoko-enhanced-rest-api')); + } + + return ['id' => (int) $input['id']]; + } + + private static function createInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['title'], + 'properties' => [ + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + ], + ]; + } + + private static function updateInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + ], + ]; + } + + private static function deleteInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'force' => ['type' => 'boolean'], + ], + ]; + } + + private static function idSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]; + } +} diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php new file mode 100644 index 0000000..3392b95 --- /dev/null +++ b/src/Features/Mcp/Posts.php @@ -0,0 +1,218 @@ + __('Create post', 'spoko-enhanced-rest-api'), + 'description' => __('Create a WordPress post with title, content, status, slug, and optional category/tag IDs.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::createInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canCreate'], + 'execute_callback' => [self::class, 'create'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]], + ]); + + wp_register_ability('spoko/posts-update', [ + 'label' => __('Update post', 'spoko-enhanced-rest-api'), + 'description' => __('Update an existing post by ID. Only provided fields are changed.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::updateInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canEdit'], + 'execute_callback' => [self::class, 'update'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/posts-delete', [ + 'label' => __('Delete post', 'spoko-enhanced-rest-api'), + 'description' => __('Move a post to trash, or permanently delete with force=true.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::deleteInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'canDelete'], + 'execute_callback' => [self::class, 'delete'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], + ]); + } + + public static function canCreate(array $input): bool|WP_Error + { + $status = (string) ($input['status'] ?? 'draft'); + $cap = in_array($status, self::PUBLISH_STATUSES, true) ? 'publish_posts' : 'edit_posts'; + + return current_user_can($cap) + ? true + : new WP_Error('forbidden', __('You cannot create posts with this status.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function canEdit(array $input): bool|WP_Error + { + $id = (int) ($input['id'] ?? 0); + if (get_post_type($id) !== 'post') { + return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + if (!current_user_can('edit_post', $id)) { + return new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + if (array_key_exists('status', $input) + && in_array((string) $input['status'], self::PUBLISH_STATUSES, true) + && !current_user_can('publish_posts') + ) { + return new WP_Error('forbidden', __('You cannot transition a post to a published state.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + return true; + } + + public static function canDelete(array $input): bool|WP_Error + { + $id = (int) ($input['id'] ?? 0); + if (get_post_type($id) !== 'post') { + return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + return current_user_can('delete_post', $id) + ? true + : new WP_Error('forbidden', __('You cannot delete this post.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function create(array $input): array|WP_Error + { + $args = [ + 'post_type' => 'post', + 'post_title' => (string) $input['title'], + 'post_content' => (string) ($input['content'] ?? ''), + 'post_status' => (string) ($input['status'] ?? 'draft'), + ]; + foreach (['slug' => 'post_name', 'excerpt' => 'post_excerpt'] as $in => $field) { + if (array_key_exists($in, $input)) { + $args[$field] = (string) $input[$in]; + } + } + if (!empty($input['category_ids'])) { + $args['post_category'] = array_map('intval', $input['category_ids']); + } + + $id = wp_insert_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + if (!empty($input['tag_ids'])) { + wp_set_post_tags($id, array_map('intval', $input['tag_ids'])); + } + + return ['id' => (int) $id]; + } + + public static function update(array $input): array|WP_Error + { + $args = ['ID' => (int) $input['id']]; + + $map = [ + 'title' => 'post_title', + 'content' => 'post_content', + 'status' => 'post_status', + 'slug' => 'post_name', + 'excerpt' => 'post_excerpt', + ]; + foreach ($map as $in => $field) { + if (array_key_exists($in, $input)) { + $args[$field] = (string) $input[$in]; + } + } + + if (array_key_exists('category_ids', $input)) { + $args['post_category'] = array_map('intval', $input['category_ids']); + } + + $id = wp_update_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + if (array_key_exists('tag_ids', $input)) { + wp_set_post_tags((int) $input['id'], array_map('intval', $input['tag_ids'])); + } + + return ['id' => (int) $id]; + } + + public static function delete(array $input): array|WP_Error + { + $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false)); + if (!$result) { + return new WP_Error('delete_failed', __('Could not delete post.', 'spoko-enhanced-rest-api')); + } + + return ['id' => (int) $input['id']]; + } + + private static function createInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['title'], + 'properties' => [ + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + ], + ]; + } + + private static function updateInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + ], + ]; + } + + private static function deleteInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'force' => ['type' => 'boolean', 'description' => 'If true, permanently delete instead of moving to trash.'], + ], + ]; + } + + private static function idSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]; + } +} diff --git a/src/Features/Mcp/Terms.php b/src/Features/Mcp/Terms.php new file mode 100644 index 0000000..78ea480 --- /dev/null +++ b/src/Features/Mcp/Terms.php @@ -0,0 +1,185 @@ + __('Create term', 'spoko-enhanced-rest-api'), + 'description' => __('Create a category or tag.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::createInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'permission'], + 'execute_callback' => [self::class, 'create'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]], + ]); + + wp_register_ability('spoko/terms-update', [ + 'label' => __('Update term', 'spoko-enhanced-rest-api'), + 'description' => __('Update name/slug/description/parent of a category or tag.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::updateInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'permission'], + 'execute_callback' => [self::class, 'update'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/terms-delete', [ + 'label' => __('Delete term', 'spoko-enhanced-rest-api'), + 'description' => __('Delete a category or tag.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::deleteInputSchema(), + 'output_schema' => self::idSchema(), + 'permission_callback' => [self::class, 'permission'], + 'execute_callback' => [self::class, 'delete'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], + ]); + } + + public static function permission(array $input): bool|WP_Error + { + $tax = (string) ($input['taxonomy'] ?? ''); + if (!in_array($tax, self::ALLOWED_TAXONOMIES, true)) { + return new WP_Error('invalid_taxonomy', __('Taxonomy not allowed.', 'spoko-enhanced-rest-api'), ['status' => 400]); + } + + $cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags'; + + return current_user_can($cap) + ? true + : new WP_Error('forbidden', __('You cannot manage this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function create(array $input): array|WP_Error + { + $args = []; + foreach (['slug', 'description'] as $f) { + if (!empty($input[$f])) { + $args[$f] = (string) $input[$f]; + } + } + if (!empty($input['parent_id'])) { + $args['parent'] = (int) $input['parent_id']; + } + + $result = wp_insert_term((string) $input['name'], (string) $input['taxonomy'], $args); + if (is_wp_error($result)) { + return $result; + } + + return ['id' => (int) $result['term_id']]; + } + + public static function update(array $input): array|WP_Error + { + $id = (int) $input['id']; + $tax = (string) $input['taxonomy']; + + if (!term_exists($id, $tax)) { + return new WP_Error('not_found', __('Term does not exist in this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + $args = []; + foreach (['name', 'slug', 'description'] as $f) { + if (array_key_exists($f, $input)) { + $args[$f] = (string) $input[$f]; + } + } + if (array_key_exists('parent_id', $input)) { + $args['parent'] = (int) $input['parent_id']; + } + if (empty($args)) { + return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); + } + + $result = wp_update_term($id, $tax, $args); + if (is_wp_error($result)) { + return $result; + } + + return ['id' => (int) $result['term_id']]; + } + + public static function delete(array $input): array|WP_Error + { + $id = (int) $input['id']; + $tax = (string) $input['taxonomy']; + + if (!term_exists($id, $tax)) { + return new WP_Error('not_found', __('Term does not exist in this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + $result = wp_delete_term($id, $tax); + if (is_wp_error($result)) { + return $result; + } + if ($result === false || $result === 0) { + return new WP_Error('delete_failed', __('Could not delete term.', 'spoko-enhanced-rest-api')); + } + + return ['id' => $id]; + } + + private static function createInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['taxonomy', 'name'], + 'properties' => [ + 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES], + 'name' => ['type' => 'string', 'minLength' => 1], + 'slug' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Only applies to categories.'], + ], + ]; + } + + private static function updateInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['taxonomy', 'id'], + 'properties' => [ + 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES], + 'id' => ['type' => 'integer', 'minimum' => 1], + 'name' => ['type' => 'string', 'minLength' => 1], + 'slug' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + ], + ]; + } + + private static function deleteInputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['taxonomy', 'id'], + 'properties' => [ + 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES], + 'id' => ['type' => 'integer', 'minimum' => 1], + ], + ]; + } + + private static function idSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]; + } +} diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php new file mode 100644 index 0000000..5156a35 --- /dev/null +++ b/src/Features/Mcp/Translations.php @@ -0,0 +1,154 @@ + __('Link Polylang translations', 'spoko-enhanced-rest-api'), + 'description' => __('Add posts to a Polylang translation group. Each post is assigned its specified language code and merged into the existing translation group of any input post that is already linked. Existing translations in other languages are preserved.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => [ + 'type' => 'object', + 'required' => ['translations'], + 'properties' => [ + 'translations' => [ + 'type' => 'object', + 'description' => 'Map of language code (e.g. "en", "pl") to post ID. At least two entries required. Language codes must be registered in Polylang.', + 'minProperties' => 2, + 'additionalProperties' => ['type' => 'integer', 'minimum' => 1], + ], + ], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'translations' => ['type' => 'object'], + ], + ], + 'permission_callback' => static function ($input) { + if (!current_user_can('edit_posts')) { + return new WP_Error('forbidden', __('You cannot edit posts.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + foreach (($input['translations'] ?? []) as $postId) { + if (!current_user_can('edit_post', (int) $postId)) { + return new WP_Error( + 'forbidden', + sprintf(__('You cannot edit post %d.', 'spoko-enhanced-rest-api'), (int) $postId), + ['status' => 403] + ); + } + } + + return true; + }, + 'execute_callback' => [self::class, 'link'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/translations-unlink', [ + 'label' => __('Unlink Polylang translation', 'spoko-enhanced-rest-api'), + 'description' => __('Remove a post from its translation group while keeping its language assignment.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + ], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ], + 'permission_callback' => static fn ($input) => current_user_can('edit_post', (int) ($input['id'] ?? 0)) + ? true + : new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]), + 'execute_callback' => [self::class, 'unlink'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], + ]); + } + + public static function link(array $input): array|WP_Error + { + if ( + !function_exists('pll_set_post_language') + || !function_exists('pll_save_post_translations') + || !function_exists('pll_languages_list') + || !function_exists('pll_get_post_translations') + ) { + return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api')); + } + + $inputMap = []; + foreach (($input['translations'] ?? []) as $lang => $postId) { + $inputMap[(string) $lang] = (int) $postId; + } + + $registeredLanguages = pll_languages_list(); + foreach (array_keys($inputMap) as $lang) { + if (!in_array($lang, $registeredLanguages, true)) { + return new WP_Error( + 'unknown_language', + sprintf(__('Language "%s" is not registered in Polylang.', 'spoko-enhanced-rest-api'), $lang), + ['status' => 400, 'registered' => $registeredLanguages] + ); + } + } + + $distinctGroups = []; + foreach ($inputMap as $postId) { + $existing = pll_get_post_translations($postId); + if (empty($existing)) { + continue; + } + ksort($existing); + $fingerprint = wp_json_encode($existing); + if ($fingerprint !== false) { + $distinctGroups[$fingerprint] = $existing; + } + } + if (count($distinctGroups) > 1) { + return new WP_Error( + 'multiple_translation_groups', + __('Input posts belong to more than one existing translation group. Unlink one side first to avoid silently fusing the groups.', 'spoko-enhanced-rest-api'), + ['status' => 409, 'groups' => array_values($distinctGroups)] + ); + } + + $merged = $distinctGroups ? reset($distinctGroups) : []; + foreach ($inputMap as $lang => $postId) { + pll_set_post_language($postId, $lang); + $merged[$lang] = $postId; + } + + pll_save_post_translations($merged); + + return ['translations' => $merged]; + } + + public static function unlink(array $input): array|WP_Error + { + if (!function_exists('pll_save_post_translations') || !function_exists('pll_get_post_language')) { + return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api')); + } + + $postId = (int) $input['id']; + $lang = pll_get_post_language($postId); + if (!$lang) { + return new WP_Error('no_language', __('Post has no language assigned.', 'spoko-enhanced-rest-api')); + } + + pll_save_post_translations([(string) $lang => $postId]); + + return ['id' => $postId]; + } +} From ac5080b676a2efc2e1becf53d1183ca71d3a0b86 Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 11:30:24 +0200 Subject: [PATCH 2/9] fix(mcp): correct tag capability and reject duplicate translation post IDs - Terms: use taxonomy cap object (manage_terms) instead of the non-existent manage_post_tags string, so admins can manage default post_tag taxonomy. - Translations: reject inputs where the same post ID is mapped to multiple languages before calling pll_set_post_language, preventing a corrupted translation set from being saved. --- src/Features/Mcp/Terms.php | 2 +- src/Features/Mcp/Translations.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Features/Mcp/Terms.php b/src/Features/Mcp/Terms.php index 78ea480..dcdb3c0 100644 --- a/src/Features/Mcp/Terms.php +++ b/src/Features/Mcp/Terms.php @@ -53,7 +53,7 @@ public static function permission(array $input): bool|WP_Error return new WP_Error('invalid_taxonomy', __('Taxonomy not allowed.', 'spoko-enhanced-rest-api'), ['status' => 400]); } - $cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags'; + $cap = get_taxonomy($tax)->cap->manage_terms; return current_user_can($cap) ? true diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php index 5156a35..c33378f 100644 --- a/src/Features/Mcp/Translations.php +++ b/src/Features/Mcp/Translations.php @@ -93,6 +93,14 @@ public static function link(array $input): array|WP_Error $inputMap[(string) $lang] = (int) $postId; } + if (count(array_unique($inputMap)) !== count($inputMap)) { + return new WP_Error( + 'duplicate_post_ids', + __('The same post ID cannot be linked to more than one language.', 'spoko-enhanced-rest-api'), + ['status' => 400, 'input' => $inputMap] + ); + } + $registeredLanguages = pll_languages_list(); foreach (array_keys($inputMap) as $lang) { if (!in_array($lang, $registeredLanguages, true)) { From a325c717efed3e9ae6039cc120d49741ef2270ee Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 13:15:41 +0200 Subject: [PATCH 3/9] fix(mcp): reject no-op post/page updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the no_updates guard in the terms-update path: if a caller invokes posts-update or pages-update with only an id and no mutable fields, return a 400 instead of letting wp_update_post fire hooks/bump timestamps. For posts, tag_ids is honored independently — a tag-only update is valid and now skips wp_update_post entirely so post-update hooks don't fire when only the tag set changes. --- src/Features/Mcp/Pages.php | 4 ++++ src/Features/Mcp/Posts.php | 21 +++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php index c4bb6bc..dd43cc0 100644 --- a/src/Features/Mcp/Pages.php +++ b/src/Features/Mcp/Pages.php @@ -132,6 +132,10 @@ public static function update(array $input): array|WP_Error $args['post_parent'] = (int) $input['parent_id']; } + if (count($args) === 1) { + return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); + } + $id = wp_update_post($args, true); if (is_wp_error($id)) { return $id; diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php index 3392b95..f9b2f0c 100644 --- a/src/Features/Mcp/Posts.php +++ b/src/Features/Mcp/Posts.php @@ -137,16 +137,25 @@ public static function update(array $input): array|WP_Error $args['post_category'] = array_map('intval', $input['category_ids']); } - $id = wp_update_post($args, true); - if (is_wp_error($id)) { - return $id; + $hasTagUpdate = array_key_exists('tag_ids', $input); + if (count($args) === 1 && !$hasTagUpdate) { + return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); } - if (array_key_exists('tag_ids', $input)) { - wp_set_post_tags((int) $input['id'], array_map('intval', $input['tag_ids'])); + $id = (int) $input['id']; + if (count($args) > 1) { + $result = wp_update_post($args, true); + if (is_wp_error($result)) { + return $result; + } + $id = (int) $result; } - return ['id' => (int) $id]; + if ($hasTagUpdate) { + wp_set_post_tags($id, array_map('intval', $input['tag_ids'])); + } + + return ['id' => $id]; } public static function delete(array $input): array|WP_Error From c59afcffe0b1e0f4ef0d01aa153c2622a297e5f6 Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 13:38:55 +0200 Subject: [PATCH 4/9] fix(mcp): surface term errors, idempotent delete, translation slot conflicts - Posts create/update: validate category_ids and tag_ids up front via a new validateTermIds() helper. Invalid IDs now return 400 before any post is created, avoiding orphan posts and silently-unapplied terms that wp_insert_post / wp_set_post_tags would have hidden. - Posts/Pages delete: when force=false and the post is already trashed, return success without re-deleting. wp_delete_post would otherwise permanently delete on retry, breaking the documented idempotent contract. - Translations link: before merging into an existing group, reject any incoming language whose slot is already held by a different post id with 409 language_slot_taken, instead of silently unlinking the previous post. --- src/Features/Mcp/Pages.php | 11 +++++- src/Features/Mcp/Posts.php | 63 +++++++++++++++++++++++++++---- src/Features/Mcp/Translations.php | 14 +++++++ 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php index dd43cc0..f2b02e1 100644 --- a/src/Features/Mcp/Pages.php +++ b/src/Features/Mcp/Pages.php @@ -146,12 +146,19 @@ public static function update(array $input): array|WP_Error public static function delete(array $input): array|WP_Error { - $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false)); + $id = (int) $input['id']; + $force = (bool) ($input['force'] ?? false); + + if (!$force && get_post_status($id) === 'trash') { + return ['id' => $id]; + } + + $result = wp_delete_post($id, $force); if (!$result) { return new WP_Error('delete_failed', __('Could not delete page.', 'spoko-enhanced-rest-api')); } - return ['id' => (int) $input['id']]; + return ['id' => $id]; } private static function createInputSchema(): array diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php index f9b2f0c..7a92f42 100644 --- a/src/Features/Mcp/Posts.php +++ b/src/Features/Mcp/Posts.php @@ -89,6 +89,16 @@ public static function canDelete(array $input): bool|WP_Error public static function create(array $input): array|WP_Error { + $catIds = !empty($input['category_ids']) ? array_map('intval', $input['category_ids']) : []; + $tagIds = !empty($input['tag_ids']) ? array_map('intval', $input['tag_ids']) : []; + + if ($error = self::validateTermIds($catIds, 'category')) { + return $error; + } + if ($error = self::validateTermIds($tagIds, 'post_tag')) { + return $error; + } + $args = [ 'post_type' => 'post', 'post_title' => (string) $input['title'], @@ -100,8 +110,8 @@ public static function create(array $input): array|WP_Error $args[$field] = (string) $input[$in]; } } - if (!empty($input['category_ids'])) { - $args['post_category'] = array_map('intval', $input['category_ids']); + if ($catIds) { + $args['post_category'] = $catIds; } $id = wp_insert_post($args, true); @@ -109,8 +119,8 @@ public static function create(array $input): array|WP_Error return $id; } - if (!empty($input['tag_ids'])) { - wp_set_post_tags($id, array_map('intval', $input['tag_ids'])); + if ($tagIds) { + wp_set_post_tags($id, $tagIds); } return ['id' => (int) $id]; @@ -134,10 +144,21 @@ public static function update(array $input): array|WP_Error } if (array_key_exists('category_ids', $input)) { - $args['post_category'] = array_map('intval', $input['category_ids']); + $catIds = array_map('intval', $input['category_ids']); + if ($error = self::validateTermIds($catIds, 'category')) { + return $error; + } + $args['post_category'] = $catIds; } $hasTagUpdate = array_key_exists('tag_ids', $input); + if ($hasTagUpdate) { + $tagIds = array_map('intval', $input['tag_ids']); + if ($error = self::validateTermIds($tagIds, 'post_tag')) { + return $error; + } + } + if (count($args) === 1 && !$hasTagUpdate) { return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); } @@ -152,7 +173,7 @@ public static function update(array $input): array|WP_Error } if ($hasTagUpdate) { - wp_set_post_tags($id, array_map('intval', $input['tag_ids'])); + wp_set_post_tags($id, $tagIds); } return ['id' => $id]; @@ -160,12 +181,38 @@ public static function update(array $input): array|WP_Error public static function delete(array $input): array|WP_Error { - $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false)); + $id = (int) $input['id']; + $force = (bool) ($input['force'] ?? false); + + if (!$force && get_post_status($id) === 'trash') { + return ['id' => $id]; + } + + $result = wp_delete_post($id, $force); if (!$result) { return new WP_Error('delete_failed', __('Could not delete post.', 'spoko-enhanced-rest-api')); } - return ['id' => (int) $input['id']]; + return ['id' => $id]; + } + + private static function validateTermIds(array $ids, string $taxonomy): ?WP_Error + { + foreach ($ids as $id) { + if (!term_exists((int) $id, $taxonomy)) { + return new WP_Error( + 'invalid_term_id', + sprintf( + __('Term %1$d does not exist in taxonomy "%2$s".', 'spoko-enhanced-rest-api'), + (int) $id, + $taxonomy + ), + ['status' => 400] + ); + } + } + + return null; } private static function createInputSchema(): array diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php index c33378f..9581e52 100644 --- a/src/Features/Mcp/Translations.php +++ b/src/Features/Mcp/Translations.php @@ -133,6 +133,20 @@ public static function link(array $input): array|WP_Error } $merged = $distinctGroups ? reset($distinctGroups) : []; + foreach ($inputMap as $lang => $postId) { + if (isset($merged[$lang]) && (int) $merged[$lang] !== $postId) { + return new WP_Error( + 'language_slot_taken', + sprintf( + __('Language "%1$s" is already linked to post %2$d in the existing translation group. Unlink it first or omit it from this request.', 'spoko-enhanced-rest-api'), + $lang, + (int) $merged[$lang] + ), + ['status' => 409, 'existing' => $merged, 'incoming' => $inputMap] + ); + } + } + foreach ($inputMap as $lang => $postId) { pll_set_post_language($postId, $lang); $merged[$lang] = $postId; From a468741ab809ab45f608f1ac39e272abccec710c Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 15:33:09 +0200 Subject: [PATCH 5/9] fix(mcp): translation post-already-linked check; page parent validation - Translations link: also reject when an incoming post is already in the existing group under a different language. Without this check, the merge loop would leave the old slot intact and add the same post under the new language, producing duplicate post IDs across two language slots. Normalized $merged to ints so the strict comparisons are reliable. - Pages create/update: validate non-zero parent_id via new validateParent() helper. wp_insert_post / wp_update_post otherwise accept any post ID as post_parent, allowing an invalid page hierarchy. parent_id=0 stays valid in update (clears the parent). --- src/Features/Mcp/Pages.php | 25 +++++++++++++++++++++++-- src/Features/Mcp/Translations.php | 19 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php index f2b02e1..ad888be 100644 --- a/src/Features/Mcp/Pages.php +++ b/src/Features/Mcp/Pages.php @@ -101,7 +101,11 @@ public static function create(array $input): array|WP_Error } } if (!empty($input['parent_id'])) { - $args['post_parent'] = (int) $input['parent_id']; + $parentId = (int) $input['parent_id']; + if ($error = self::validateParent($parentId)) { + return $error; + } + $args['post_parent'] = $parentId; } $id = wp_insert_post($args, true); @@ -129,7 +133,11 @@ public static function update(array $input): array|WP_Error } } if (array_key_exists('parent_id', $input)) { - $args['post_parent'] = (int) $input['parent_id']; + $parentId = (int) $input['parent_id']; + if ($parentId !== 0 && ($error = self::validateParent($parentId))) { + return $error; + } + $args['post_parent'] = $parentId; } if (count($args) === 1) { @@ -161,6 +169,19 @@ public static function delete(array $input): array|WP_Error return ['id' => $id]; } + private static function validateParent(int $parentId): ?WP_Error + { + if (get_post_type($parentId) !== 'page') { + return new WP_Error( + 'invalid_parent', + sprintf(__('Parent %d is not a page.', 'spoko-enhanced-rest-api'), $parentId), + ['status' => 400] + ); + } + + return null; + } + private static function createInputSchema(): array { return [ diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php index 9581e52..4ef63c9 100644 --- a/src/Features/Mcp/Translations.php +++ b/src/Features/Mcp/Translations.php @@ -132,15 +132,28 @@ public static function link(array $input): array|WP_Error ); } - $merged = $distinctGroups ? reset($distinctGroups) : []; + $merged = $distinctGroups ? array_map('intval', reset($distinctGroups)) : []; foreach ($inputMap as $lang => $postId) { - if (isset($merged[$lang]) && (int) $merged[$lang] !== $postId) { + if (isset($merged[$lang]) && $merged[$lang] !== $postId) { return new WP_Error( 'language_slot_taken', sprintf( __('Language "%1$s" is already linked to post %2$d in the existing translation group. Unlink it first or omit it from this request.', 'spoko-enhanced-rest-api'), $lang, - (int) $merged[$lang] + $merged[$lang] + ), + ['status' => 409, 'existing' => $merged, 'incoming' => $inputMap] + ); + } + $existingLang = array_search($postId, $merged, true); + if ($existingLang !== false && $existingLang !== $lang) { + return new WP_Error( + 'post_already_linked', + sprintf( + __('Post %1$d is already linked to language "%2$s" in the existing translation group; relinking to "%3$s" would duplicate it. Unlink the post first.', 'spoko-enhanced-rest-api'), + $postId, + (string) $existingLang, + $lang ), ['status' => 409, 'existing' => $merged, 'incoming' => $inputMap] ); From 46c52b78b4ae9c99472b7f8b1b74a3052412cc8b Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 15:51:44 +0200 Subject: [PATCH 6/9] feat(mcp): read abilities (posts-get, pages-get, posts-list) + Rank Math SEO helper - New Read.php exposes spoko/posts-get, spoko/pages-get, and spoko/posts-list so LLM clients can fetch the full editable shape (title, content, excerpt, taxonomies, parent, featured image, Rank Math SEO) in one ability call. Per-object permission uses WP read_post meta cap; list requires edit_posts. - New SeoMeta helper centralizes Rank Math meta keys (title, description, focus_keyword, canonical_url, robots, og_title, og_description, og_image_id) with a validate/read/write split so callers can fail fast before mutating WP state. - AbilitiesFeature registers Read alongside the existing CRUD abilities and the category description reflects the broader surface. --- src/Features/Mcp/AbilitiesFeature.php | 3 +- src/Features/Mcp/Read.php | 253 ++++++++++++++++++++++++++ src/Features/Mcp/SeoMeta.php | 135 ++++++++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/Features/Mcp/Read.php create mode 100644 src/Features/Mcp/SeoMeta.php diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php index acbb7a9..2ccb712 100644 --- a/src/Features/Mcp/AbilitiesFeature.php +++ b/src/Features/Mcp/AbilitiesFeature.php @@ -33,7 +33,7 @@ public function registerCategory(): void 'spoko-content', [ 'label' => __('SPOKO content workflows', 'spoko-enhanced-rest-api'), - 'description' => __('Headless content management: posts/pages/terms CRUD and Polylang translation pairing.', 'spoko-enhanced-rest-api'), + 'description' => __('Headless content management: read/list/CRUD for posts/pages/terms, Rank Math SEO, featured images, and Polylang translation pairing.', 'spoko-enhanced-rest-api'), ] ); } @@ -44,6 +44,7 @@ public function registerAbilities(): void return; } + Read::register(); Posts::register(); Pages::register(); Terms::register(); diff --git a/src/Features/Mcp/Read.php b/src/Features/Mcp/Read.php new file mode 100644 index 0000000..046cc74 --- /dev/null +++ b/src/Features/Mcp/Read.php @@ -0,0 +1,253 @@ + __('Get post', 'spoko-enhanced-rest-api'), + 'description' => __('Fetch a single post with title, content, excerpt, taxonomies, featured image, and Rank Math SEO fields — everything needed to recommend excerpt or SEO copy.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::idSchema(), + 'output_schema' => self::postOutputSchema(), + 'permission_callback' => [self::class, 'canRead'], + 'execute_callback' => [self::class, 'getPost'], + 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/pages-get', [ + 'label' => __('Get page', 'spoko-enhanced-rest-api'), + 'description' => __('Fetch a single page with title, content, excerpt, parent, featured image, and Rank Math SEO fields.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::idSchema(), + 'output_schema' => self::pageOutputSchema(), + 'permission_callback' => [self::class, 'canRead'], + 'execute_callback' => [self::class, 'getPage'], + 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]], + ]); + + wp_register_ability('spoko/posts-list', [ + 'label' => __('List posts', 'spoko-enhanced-rest-api'), + 'description' => __('Paginated listing of posts with optional search/status/category/tag filters. Returns id, title, excerpt, status, and modified date for browsing.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::listInputSchema(), + 'output_schema' => self::listOutputSchema(), + 'permission_callback' => static fn () => current_user_can('edit_posts') + ? true + : new WP_Error('forbidden', __('You cannot list posts.', 'spoko-enhanced-rest-api'), ['status' => 403]), + 'execute_callback' => [self::class, 'listPosts'], + 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]], + ]); + } + + public static function canRead(array $input): bool|WP_Error + { + $id = (int) ($input['id'] ?? 0); + if (!get_post($id)) { + return new WP_Error('not_found', __('Post does not exist.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + return current_user_can('read_post', $id) + ? true + : new WP_Error('forbidden', __('You cannot read this post.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + public static function getPost(array $input): array|WP_Error + { + $id = (int) $input['id']; + $post = get_post($id); + if (!$post || $post->post_type !== 'post') { + return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + return [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'date' => $post->post_date_gmt, + 'modified' => $post->post_modified_gmt, + 'author_id' => (int) $post->post_author, + 'category_ids' => wp_get_post_categories($id, ['fields' => 'ids']), + 'tag_ids' => wp_get_post_tags($id, ['fields' => 'ids']), + 'featured_image_id' => (int) get_post_thumbnail_id($id) ?: null, + 'seo' => SeoMeta::read($id), + ]; + } + + public static function getPage(array $input): array|WP_Error + { + $id = (int) $input['id']; + $post = get_post($id); + if (!$post || $post->post_type !== 'page') { + return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + return [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'date' => $post->post_date_gmt, + 'modified' => $post->post_modified_gmt, + 'author_id' => (int) $post->post_author, + 'parent_id' => (int) $post->post_parent, + 'featured_image_id' => (int) get_post_thumbnail_id($id) ?: null, + 'seo' => SeoMeta::read($id), + ]; + } + + public static function listPosts(array $input): array + { + $perPage = max(1, min(100, (int) ($input['per_page'] ?? 20))); + $page = max(1, (int) ($input['page'] ?? 1)); + + $args = [ + 'post_type' => 'post', + 'post_status' => $input['status'] ?? ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => $perPage, + 'paged' => $page, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => false, + ]; + if (!empty($input['search'])) { + $args['s'] = (string) $input['search']; + } + if (!empty($input['category_id'])) { + $args['cat'] = (int) $input['category_id']; + } + if (!empty($input['tag_id'])) { + $args['tag_id'] = (int) $input['tag_id']; + } + + $query = new WP_Query($args); + $items = []; + foreach ($query->posts as $post) { + $items[] = [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'excerpt' => $post->post_excerpt, + 'status' => $post->post_status, + 'modified' => $post->post_modified_gmt, + ]; + } + + return [ + 'items' => $items, + 'page' => $page, + 'per_page' => $perPage, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ]; + } + + private static function idSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + ], + ]; + } + + private static function listInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'search' => ['type' => 'string'], + 'status' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => ['publish', 'draft', 'pending', 'private', 'future']], + ], + 'category_id' => ['type' => 'integer', 'minimum' => 1], + 'tag_id' => ['type' => 'integer', 'minimum' => 1], + 'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20], + 'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1], + ], + ]; + } + + private static function postOutputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'slug' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'date' => ['type' => 'string'], + 'modified' => ['type' => 'string'], + 'author_id' => ['type' => 'integer'], + 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'featured_image_id' => ['type' => ['integer', 'null']], + 'seo' => ['type' => 'object'], + ], + ]; + } + + private static function pageOutputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'slug' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'date' => ['type' => 'string'], + 'modified' => ['type' => 'string'], + 'author_id' => ['type' => 'integer'], + 'parent_id' => ['type' => 'integer'], + 'featured_image_id' => ['type' => ['integer', 'null']], + 'seo' => ['type' => 'object'], + ], + ]; + } + + private static function listOutputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'modified' => ['type' => 'string'], + ], + ], + ], + 'page' => ['type' => 'integer'], + 'per_page' => ['type' => 'integer'], + 'total' => ['type' => 'integer'], + 'total_pages' => ['type' => 'integer'], + ], + ]; + } +} diff --git a/src/Features/Mcp/SeoMeta.php b/src/Features/Mcp/SeoMeta.php new file mode 100644 index 0000000..028095e --- /dev/null +++ b/src/Features/Mcp/SeoMeta.php @@ -0,0 +1,135 @@ + 'rank_math_title', + 'description' => 'rank_math_description', + 'focus_keyword' => 'rank_math_focus_keyword', + 'canonical_url' => 'rank_math_canonical_url', + 'robots' => 'rank_math_robots', + 'og_title' => 'rank_math_facebook_title', + 'og_description' => 'rank_math_facebook_description', + 'og_image_id' => 'rank_math_facebook_image_id', + ]; + + private const ROBOTS_VALUES = ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex']; + + public static function isActive(): bool + { + return defined('RANK_MATH_VERSION') || class_exists('RankMath\\Helper'); + } + + public static function read(int $postId): array + { + $out = []; + foreach (self::META_KEYS as $apiKey => $metaKey) { + $value = get_post_meta($postId, $metaKey, true); + $out[$apiKey] = match ($apiKey) { + 'og_image_id' => $value === '' ? null : (int) $value, + 'robots' => is_array($value) ? array_values($value) : [], + default => (string) $value, + }; + } + + return $out; + } + + public static function validate(array $seo): ?WP_Error + { + if (!self::isActive()) { + return new WP_Error( + 'rank_math_inactive', + __('Rank Math is not active; SEO fields cannot be written.', 'spoko-enhanced-rest-api'), + ['status' => 400] + ); + } + + foreach ($seo as $apiKey => $value) { + if (!isset(self::META_KEYS[$apiKey])) { + continue; + } + + if ($apiKey === 'og_image_id' && $value !== null) { + $id = (int) $value; + if (get_post_type($id) !== 'attachment') { + return new WP_Error( + 'invalid_og_image', + sprintf(__('OG image attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id), + ['status' => 400] + ); + } + } + + if ($apiKey === 'robots') { + $list = array_values(array_unique(array_filter(array_map('strval', (array) $value)))); + foreach ($list as $directive) { + if (!in_array($directive, self::ROBOTS_VALUES, true)) { + return new WP_Error( + 'invalid_robots_directive', + sprintf(__('Robots directive "%s" is not supported.', 'spoko-enhanced-rest-api'), $directive), + ['status' => 400, 'allowed' => self::ROBOTS_VALUES] + ); + } + } + } + } + + return null; + } + + public static function write(int $postId, array $seo): void + { + foreach ($seo as $apiKey => $value) { + if (!isset(self::META_KEYS[$apiKey])) { + continue; + } + $metaKey = self::META_KEYS[$apiKey]; + + if ($apiKey === 'og_image_id') { + if ($value === null) { + delete_post_meta($postId, $metaKey); + } else { + update_post_meta($postId, $metaKey, (int) $value); + } + continue; + } + + if ($apiKey === 'robots') { + $list = array_values(array_unique(array_filter(array_map('strval', (array) $value)))); + update_post_meta($postId, $metaKey, $list); + continue; + } + + update_post_meta($postId, $metaKey, (string) $value); + } + } + + public static function inputSchema(): array + { + return [ + 'type' => 'object', + 'description' => 'Rank Math SEO fields. Only keys included are written; omitted keys are unchanged. Use og_image_id=null to clear the OG image.', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'Meta title (overrides post title in search/social).'], + 'description' => ['type' => 'string', 'description' => 'Meta description (search snippet).'], + 'focus_keyword' => ['type' => 'string', 'description' => 'Focus keyword; multiple values comma-separated.'], + 'canonical_url' => ['type' => 'string', 'description' => 'Canonical URL on the Astro frontend (polo.blue/...).'], + 'robots' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::ROBOTS_VALUES], + ], + 'og_title' => ['type' => 'string'], + 'og_description' => ['type' => 'string'], + 'og_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1], + ], + 'additionalProperties' => false, + ]; + } +} From c5d6b953928ab7c752612695a1842a6c4f8d1df8 Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 15:55:36 +0200 Subject: [PATCH 7/9] feat(mcp): seo and featured_image_id on posts/pages create+update Extends create and update for both Posts and Pages with two optional fields: - featured_image_id: attachment ID validated upfront via new validateAttachment helper; null on update clears _thumbnail_id. - seo: object of Rank Math fields. Validated upfront via SeoMeta::validate (Rank Math active, og image attachment exists, robots directives in allowlist) before any post mutation, so invalid SEO input can't leave an orphan post or half-applied state. no_updates guard extended to recognize featured_image_id and seo as valid mutations so a request setting only those isn't rejected as a no-op. --- src/Features/Mcp/Pages.php | 104 ++++++++++++++++++++++++++++++------- src/Features/Mcp/Posts.php | 96 ++++++++++++++++++++++++++++------ 2 files changed, 166 insertions(+), 34 deletions(-) diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php index ad888be..73a3af8 100644 --- a/src/Features/Mcp/Pages.php +++ b/src/Features/Mcp/Pages.php @@ -89,6 +89,19 @@ public static function canDelete(array $input): bool|WP_Error public static function create(array $input): array|WP_Error { + $featuredId = null; + if (!empty($input['featured_image_id'])) { + $featuredId = (int) $input['featured_image_id']; + if ($error = self::validateAttachment($featuredId)) { + return $error; + } + } + + $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : []; + if ($seo && ($error = SeoMeta::validate($seo))) { + return $error; + } + $args = [ 'post_type' => 'page', 'post_title' => (string) $input['title'], @@ -113,6 +126,14 @@ public static function create(array $input): array|WP_Error return $id; } + if ($featuredId !== null) { + set_post_thumbnail($id, $featuredId); + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + return ['id' => (int) $id]; } @@ -140,16 +161,46 @@ public static function update(array $input): array|WP_Error $args['post_parent'] = $parentId; } - if (count($args) === 1) { + $hasFeaturedUpdate = array_key_exists('featured_image_id', $input); + $featuredId = null; + if ($hasFeaturedUpdate && $input['featured_image_id'] !== null) { + $featuredId = (int) $input['featured_image_id']; + if ($error = self::validateAttachment($featuredId)) { + return $error; + } + } + + $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : []; + if ($seo && ($error = SeoMeta::validate($seo))) { + return $error; + } + + if (count($args) === 1 && !$hasFeaturedUpdate && !$seo) { return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); } - $id = wp_update_post($args, true); - if (is_wp_error($id)) { - return $id; + $id = (int) $input['id']; + if (count($args) > 1) { + $result = wp_update_post($args, true); + if (is_wp_error($result)) { + return $result; + } + $id = (int) $result; } - return ['id' => (int) $id]; + if ($hasFeaturedUpdate) { + if ($featuredId === null) { + delete_post_thumbnail($id); + } else { + set_post_thumbnail($id, $featuredId); + } + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + + return ['id' => $id]; } public static function delete(array $input): array|WP_Error @@ -169,6 +220,19 @@ public static function delete(array $input): array|WP_Error return ['id' => $id]; } + private static function validateAttachment(int $id): ?WP_Error + { + if (get_post_type($id) !== 'attachment') { + return new WP_Error( + 'invalid_attachment', + sprintf(__('Attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id), + ['status' => 400] + ); + } + + return null; + } + private static function validateParent(int $parentId): ?WP_Error { if (get_post_type($parentId) !== 'page') { @@ -188,12 +252,14 @@ private static function createInputSchema(): array 'type' => 'object', 'required' => ['title'], 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1], - 'content' => ['type' => 'string'], - 'excerpt' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], - 'slug' => ['type' => 'string'], - 'parent_id' => ['type' => 'integer', 'minimum' => 0], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + 'featured_image_id' => ['type' => 'integer', 'minimum' => 1], + 'seo' => SeoMeta::inputSchema(), ], ]; } @@ -204,13 +270,15 @@ private static function updateInputSchema(): array 'type' => 'object', 'required' => ['id'], 'properties' => [ - 'id' => ['type' => 'integer', 'minimum' => 1], - 'title' => ['type' => 'string', 'minLength' => 1], - 'content' => ['type' => 'string'], - 'excerpt' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], - 'slug' => ['type' => 'string'], - 'parent_id' => ['type' => 'integer', 'minimum' => 0], + 'id' => ['type' => 'integer', 'minimum' => 1], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'], + 'seo' => SeoMeta::inputSchema(), ], ]; } diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php index 7a92f42..31e2356 100644 --- a/src/Features/Mcp/Posts.php +++ b/src/Features/Mcp/Posts.php @@ -99,6 +99,19 @@ public static function create(array $input): array|WP_Error return $error; } + $featuredId = null; + if (!empty($input['featured_image_id'])) { + $featuredId = (int) $input['featured_image_id']; + if ($error = self::validateAttachment($featuredId)) { + return $error; + } + } + + $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : []; + if ($seo && ($error = SeoMeta::validate($seo))) { + return $error; + } + $args = [ 'post_type' => 'post', 'post_title' => (string) $input['title'], @@ -123,6 +136,14 @@ public static function create(array $input): array|WP_Error wp_set_post_tags($id, $tagIds); } + if ($featuredId !== null) { + set_post_thumbnail($id, $featuredId); + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + return ['id' => (int) $id]; } @@ -159,7 +180,21 @@ public static function update(array $input): array|WP_Error } } - if (count($args) === 1 && !$hasTagUpdate) { + $hasFeaturedUpdate = array_key_exists('featured_image_id', $input); + $featuredId = null; + if ($hasFeaturedUpdate && $input['featured_image_id'] !== null) { + $featuredId = (int) $input['featured_image_id']; + if ($error = self::validateAttachment($featuredId)) { + return $error; + } + } + + $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : []; + if ($seo && ($error = SeoMeta::validate($seo))) { + return $error; + } + + if (count($args) === 1 && !$hasTagUpdate && !$hasFeaturedUpdate && !$seo) { return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]); } @@ -176,6 +211,18 @@ public static function update(array $input): array|WP_Error wp_set_post_tags($id, $tagIds); } + if ($hasFeaturedUpdate) { + if ($featuredId === null) { + delete_post_thumbnail($id); + } else { + set_post_thumbnail($id, $featuredId); + } + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + return ['id' => $id]; } @@ -196,6 +243,19 @@ public static function delete(array $input): array|WP_Error return ['id' => $id]; } + private static function validateAttachment(int $id): ?WP_Error + { + if (get_post_type($id) !== 'attachment') { + return new WP_Error( + 'invalid_attachment', + sprintf(__('Attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id), + ['status' => 400] + ); + } + + return null; + } + private static function validateTermIds(array $ids, string $taxonomy): ?WP_Error { foreach ($ids as $id) { @@ -221,13 +281,15 @@ private static function createInputSchema(): array 'type' => 'object', 'required' => ['title'], 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1], - 'content' => ['type' => 'string'], - 'excerpt' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], - 'slug' => ['type' => 'string'], - 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], - 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'featured_image_id' => ['type' => 'integer', 'minimum' => 1], + 'seo' => SeoMeta::inputSchema(), ], ]; } @@ -238,14 +300,16 @@ private static function updateInputSchema(): array 'type' => 'object', 'required' => ['id'], 'properties' => [ - 'id' => ['type' => 'integer', 'minimum' => 1], - 'title' => ['type' => 'string', 'minLength' => 1], - 'content' => ['type' => 'string'], - 'excerpt' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], - 'slug' => ['type' => 'string'], - 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], - 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'id' => ['type' => 'integer', 'minimum' => 1], + 'title' => ['type' => 'string', 'minLength' => 1], + 'content' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'slug' => ['type' => 'string'], + 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], + 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'], + 'seo' => SeoMeta::inputSchema(), ], ]; } From a90097fa728832949cffc25bfd0a38c0436d9ca0 Mon Sep 17 00:00:00 2001 From: Szymon Date: Mon, 18 May 2026 17:20:25 +0200 Subject: [PATCH 8/9] feat(mcp): posts-translate ability + media translation helper Solves the headless Polylang Pro pain point where translating a post leaves the new language version pointing at source-language attachments, forcing manual "Add translation" clicks on every image before it can be reused. - New MediaTranslator helper: ensureTranslation(attId, target, source) duplicates an attachment record sharing the same file (_wp_attached_file, _wp_attachment_metadata, alt) and links it into the Polylang translation group. No-op when Polylang media translations are disabled. rewriteContent parses Gutenberg blocks and patches id/ids on core/image, core/cover, core/media-text, core/gallery, plus the wp-image-NNN class references. - New spoko/posts-translate ability: copies a source post into a new language, applies optional overrides (title/content/excerpt/slug/status/ featured_image_id), translates featured + in-content media, and links the pair via Polylang. Rejects same-language requests, missing source language, and existing translations with a 409. Returns the new post id plus a per-source media_translations report so callers can audit what was created. - Registered in AbilitiesFeature alongside the existing Polylang-gated Translations ability. --- src/Features/Mcp/AbilitiesFeature.php | 1 + src/Features/Mcp/MediaTranslator.php | 133 ++++++++++++++++ src/Features/Mcp/Translate.php | 217 ++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/Features/Mcp/MediaTranslator.php create mode 100644 src/Features/Mcp/Translate.php diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php index 2ccb712..739c35f 100644 --- a/src/Features/Mcp/AbilitiesFeature.php +++ b/src/Features/Mcp/AbilitiesFeature.php @@ -51,6 +51,7 @@ public function registerAbilities(): void if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) { Translations::register(); + Translate::register(); } } } diff --git a/src/Features/Mcp/MediaTranslator.php b/src/Features/Mcp/MediaTranslator.php new file mode 100644 index 0000000..b60e451 --- /dev/null +++ b/src/Features/Mcp/MediaTranslator.php @@ -0,0 +1,133 @@ +post_type !== 'attachment') { + return $attachmentId; + } + + $existing = (int) pll_get_post($attachmentId, $targetLang); + if ($existing > 0) { + return $existing; + } + + if (!pll_get_post_language($attachmentId)) { + pll_set_post_language($attachmentId, $sourceLang); + } + + $newId = wp_insert_post([ + 'post_type' => 'attachment', + 'post_title' => $source->post_title, + 'post_content' => $source->post_content, + 'post_excerpt' => $source->post_excerpt, + 'post_mime_type' => $source->post_mime_type, + 'post_status' => 'inherit', + 'post_parent' => 0, + 'guid' => $source->guid, + ], true); + + if (is_wp_error($newId) || !$newId) { + return $attachmentId; + } + + foreach (self::ATTACHMENT_META_KEYS as $metaKey) { + $value = get_post_meta($attachmentId, $metaKey, true); + if ($value !== '' && $value !== false && $value !== null) { + update_post_meta($newId, $metaKey, $value); + } + } + + pll_set_post_language($newId, $targetLang); + + $group = pll_get_post_translations($attachmentId); + $group[$targetLang] = $newId; + pll_save_post_translations($group); + + return $newId; + } + + public static function rewriteContent(string $content, string $targetLang, string $sourceLang): array + { + if ($content === '' || !self::isMediaTranslated()) { + return ['content' => $content, 'translations' => []]; + } + + $translations = []; + $blocks = parse_blocks($content); + $newBlocks = self::walkBlocks($blocks, $targetLang, $sourceLang, $translations); + $newContent = serialize_blocks($newBlocks); + + foreach ($translations as $oldId => $newId) { + if ($oldId !== $newId) { + $newContent = str_replace("wp-image-{$oldId}", "wp-image-{$newId}", $newContent); + } + } + + return ['content' => $newContent, 'translations' => $translations]; + } + + private static function walkBlocks(array $blocks, string $targetLang, string $sourceLang, array &$translations): array + { + foreach ($blocks as &$block) { + $name = $block['blockName'] ?? null; + $attrs = $block['attrs'] ?? []; + + if (in_array($name, ['core/image', 'core/cover', 'core/media-text'], true) && !empty($attrs['id'])) { + $attrs['id'] = self::resolve((int) $attrs['id'], $targetLang, $sourceLang, $translations); + } + + if ($name === 'core/gallery' && !empty($attrs['ids']) && is_array($attrs['ids'])) { + $attrs['ids'] = array_map( + fn ($id) => self::resolve((int) $id, $targetLang, $sourceLang, $translations), + $attrs['ids'] + ); + } + + $block['attrs'] = $attrs; + + if (!empty($block['innerBlocks'])) { + $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $targetLang, $sourceLang, $translations); + } + } + + return $blocks; + } + + private static function resolve(int $sourceId, string $targetLang, string $sourceLang, array &$translations): int + { + if ($sourceId <= 0) { + return $sourceId; + } + if (isset($translations[$sourceId])) { + return $translations[$sourceId]; + } + + $translated = self::ensureTranslation($sourceId, $targetLang, $sourceLang); + $translations[$sourceId] = $translated; + + return $translated; + } +} diff --git a/src/Features/Mcp/Translate.php b/src/Features/Mcp/Translate.php new file mode 100644 index 0000000..f1a8165 --- /dev/null +++ b/src/Features/Mcp/Translate.php @@ -0,0 +1,217 @@ + __('Translate post', 'spoko-enhanced-rest-api'), + 'description' => __('Copy a post into a new language, link the pair via Polylang, and auto-translate referenced media (featured image + Gutenberg image/gallery/cover/media-text blocks) so the new post does not reference foreign-language attachments.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::inputSchema(), + 'output_schema' => self::outputSchema(), + 'permission_callback' => [self::class, 'canTranslate'], + 'execute_callback' => [self::class, 'execute'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]], + ]); + } + + public static function canTranslate(array $input): bool|WP_Error + { + $sourceId = (int) ($input['source_id'] ?? 0); + if (get_post_type($sourceId) !== 'post') { + return new WP_Error('not_a_post', __('Source is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + if (!current_user_can('edit_post', $sourceId)) { + return new WP_Error('forbidden', __('You cannot read the source post.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + if (!current_user_can('edit_posts')) { + return new WP_Error('forbidden', __('You cannot create posts.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + return true; + } + + public static function execute(array $input): array|WP_Error + { + if ( + !function_exists('pll_set_post_language') + || !function_exists('pll_save_post_translations') + || !function_exists('pll_languages_list') + || !function_exists('pll_get_post_translations') + || !function_exists('pll_get_post_language') + ) { + return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api')); + } + + $sourceId = (int) $input['source_id']; + $targetLang = (string) $input['language']; + $overrides = (array) ($input['overrides'] ?? []); + $duplicateMedia = !array_key_exists('duplicate_media', $input) || (bool) $input['duplicate_media']; + + $registeredLanguages = pll_languages_list(); + if (!in_array($targetLang, $registeredLanguages, true)) { + return new WP_Error( + 'unknown_language', + sprintf(__('Language "%s" is not registered in Polylang.', 'spoko-enhanced-rest-api'), $targetLang), + ['status' => 400, 'registered' => $registeredLanguages] + ); + } + + $source = get_post($sourceId); + $sourceLang = (string) pll_get_post_language($sourceId); + if (!$sourceLang) { + return new WP_Error( + 'source_no_language', + __('Source post has no Polylang language assigned. Assign one before translating.', 'spoko-enhanced-rest-api'), + ['status' => 400] + ); + } + if ($sourceLang === $targetLang) { + return new WP_Error( + 'same_language', + __('Source and target languages are identical.', 'spoko-enhanced-rest-api'), + ['status' => 400] + ); + } + + $existingTranslation = (int) (pll_get_post($sourceId, $targetLang) ?: 0); + if ($existingTranslation > 0) { + return new WP_Error( + 'translation_exists', + sprintf(__('Source post already has a "%s" translation (post %d). Use posts-update to modify it.', 'spoko-enhanced-rest-api'), $targetLang, $existingTranslation), + ['status' => 409, 'existing_id' => $existingTranslation] + ); + } + + $content = (string) ($overrides['content'] ?? $source->post_content); + $mediaTranslations = []; + + if ($duplicateMedia && $content !== '') { + $rewrite = MediaTranslator::rewriteContent($content, $targetLang, $sourceLang); + $content = $rewrite['content']; + $mediaTranslations = $rewrite['translations']; + } + + $args = [ + 'post_type' => 'post', + 'post_title' => (string) ($overrides['title'] ?? $source->post_title), + 'post_content' => $content, + 'post_excerpt' => (string) ($overrides['excerpt'] ?? $source->post_excerpt), + 'post_status' => (string) ($overrides['status'] ?? 'draft'), + 'post_name' => (string) ($overrides['slug'] ?? $source->post_name), + ]; + + $newId = wp_insert_post($args, true); + if (is_wp_error($newId)) { + return $newId; + } + + $featuredId = self::resolveFeaturedImage($input, $source->ID, $targetLang, $sourceLang, $duplicateMedia, $mediaTranslations); + if ($featuredId instanceof WP_Error) { + return $featuredId; + } + if ($featuredId !== null) { + set_post_thumbnail($newId, $featuredId); + } + + pll_set_post_language($newId, $targetLang); + $group = pll_get_post_translations($sourceId); + $group[$sourceLang] = $sourceId; + $group[$targetLang] = $newId; + pll_save_post_translations($group); + + $report = []; + foreach ($mediaTranslations as $oldId => $newAttId) { + $report[] = [ + 'source' => (int) $oldId, + 'translation' => (int) $newAttId, + 'created' => (int) $oldId !== (int) $newAttId, + ]; + } + + return [ + 'id' => (int) $newId, + 'media_translations' => $report, + ]; + } + + private static function resolveFeaturedImage(array $input, int $sourceId, string $targetLang, string $sourceLang, bool $duplicateMedia, array &$mediaTranslations): int|null|WP_Error + { + $overrides = (array) ($input['overrides'] ?? []); + + if (array_key_exists('featured_image_id', $overrides)) { + return $overrides['featured_image_id'] === null ? null : (int) $overrides['featured_image_id']; + } + + $sourceThumb = (int) get_post_thumbnail_id($sourceId); + if ($sourceThumb <= 0) { + return null; + } + + if (!$duplicateMedia || !MediaTranslator::isMediaTranslated()) { + return $sourceThumb; + } + + $translated = MediaTranslator::ensureTranslation($sourceThumb, $targetLang, $sourceLang); + $mediaTranslations[$sourceThumb] = $translated; + + return $translated; + } + + private static function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['source_id', 'language'], + 'properties' => [ + 'source_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Post ID to translate from.'], + 'language' => ['type' => 'string', 'description' => 'Polylang language code of the new post (e.g. "en", "pl").'], + 'overrides' => [ + 'type' => 'object', + 'description' => 'Optional overrides applied on top of the copied source fields.', + 'properties' => [ + 'title' => ['type' => 'string'], + 'content' => ['type' => 'string', 'description' => 'If provided, replaces source content and is itself walked for embedded media when duplicate_media is true.'], + 'excerpt' => ['type' => 'string'], + 'slug' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']], + 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1], + ], + ], + 'duplicate_media' => [ + 'type' => 'boolean', + 'default' => true, + 'description' => 'When true and Polylang media translations are enabled, walk the content body and featured image, creating language-tagged attachment copies that share the underlying file. No-op when Polylang media translation is disabled.', + ], + ], + ]; + } + + private static function outputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'media_translations' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'source' => ['type' => 'integer'], + 'translation' => ['type' => 'integer'], + 'created' => ['type' => 'boolean'], + ], + ], + ], + ], + ]; + } +} From 289483d7b41404b83f5c7931c836845223bdc522 Mon Sep 17 00:00:00 2001 From: Szymon Date: Tue, 19 May 2026 14:52:04 +0200 Subject: [PATCH 9/9] feat(mcp): attachments-rewire ability for backfilling existing posts Posts created before posts-translate existed can have featured image and in-content attachments tagged with the wrong Polylang language (or no language at all). spoko/attachments-rewire fixes them in place. - Refactored MediaTranslator to extract a shared walkContent + walkBlocks helper that both rewriteContent (translate-copy) and the new rewireContentForLanguage (backfill) use through a resolver closure. Pulled the attachment-duplication logic into duplicateAttachment so ensureTranslation and rewireForLanguage share it. - rewireForLanguage(attId, postLang) has three branches: same-language is a no-op, no-language attachment gets the post's lang assigned in place (avoiding a needless duplicate), other-language attachment is swapped for the matching translation (created on demand via duplicateAttachment). - New Rewire ability accepts post or page IDs, scans featured image and Gutenberg blocks, and returns a per-attachment report. wp_update_post is skipped when the content body didn't change so already-correct posts truly no-op. --- src/Features/Mcp/AbilitiesFeature.php | 1 + src/Features/Mcp/MediaTranslator.php | 146 ++++++++++++++++++-------- src/Features/Mcp/Rewire.php | 141 +++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 src/Features/Mcp/Rewire.php diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php index 739c35f..4b4ba7b 100644 --- a/src/Features/Mcp/AbilitiesFeature.php +++ b/src/Features/Mcp/AbilitiesFeature.php @@ -52,6 +52,7 @@ public function registerAbilities(): void if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) { Translations::register(); Translate::register(); + Rewire::register(); } } } diff --git a/src/Features/Mcp/MediaTranslator.php b/src/Features/Mcp/MediaTranslator.php index b60e451..ba030e6 100644 --- a/src/Features/Mcp/MediaTranslator.php +++ b/src/Features/Mcp/MediaTranslator.php @@ -4,6 +4,8 @@ namespace Spoko\EnhancedRestAPI\Features\Mcp; +use WP_Post; + final class MediaTranslator { private const ATTACHMENT_META_KEYS = [ @@ -38,35 +40,37 @@ public static function ensureTranslation(int $attachmentId, string $targetLang, pll_set_post_language($attachmentId, $sourceLang); } - $newId = wp_insert_post([ - 'post_type' => 'attachment', - 'post_title' => $source->post_title, - 'post_content' => $source->post_content, - 'post_excerpt' => $source->post_excerpt, - 'post_mime_type' => $source->post_mime_type, - 'post_status' => 'inherit', - 'post_parent' => 0, - 'guid' => $source->guid, - ], true); + return self::duplicateAttachment($source, $targetLang); + } - if (is_wp_error($newId) || !$newId) { + public static function rewireForLanguage(int $attachmentId, string $postLang): int + { + if (!self::isMediaTranslated() || $attachmentId <= 0) { return $attachmentId; } - foreach (self::ATTACHMENT_META_KEYS as $metaKey) { - $value = get_post_meta($attachmentId, $metaKey, true); - if ($value !== '' && $value !== false && $value !== null) { - update_post_meta($newId, $metaKey, $value); - } + $source = get_post($attachmentId); + if (!$source || $source->post_type !== 'attachment') { + return $attachmentId; } - pll_set_post_language($newId, $targetLang); + $currentLang = (string) pll_get_post_language($attachmentId); - $group = pll_get_post_translations($attachmentId); - $group[$targetLang] = $newId; - pll_save_post_translations($group); + if ($currentLang === $postLang) { + return $attachmentId; + } + + if ($currentLang === '') { + pll_set_post_language($attachmentId, $postLang); + return $attachmentId; + } - return $newId; + $existing = (int) pll_get_post($attachmentId, $postLang); + if ($existing > 0) { + return $existing; + } + + return self::duplicateAttachment($source, $postLang); } public static function rewriteContent(string $content, string $targetLang, string $sourceLang): array @@ -76,58 +80,118 @@ public static function rewriteContent(string $content, string $targetLang, strin } $translations = []; - $blocks = parse_blocks($content); - $newBlocks = self::walkBlocks($blocks, $targetLang, $sourceLang, $translations); - $newContent = serialize_blocks($newBlocks); + $resolve = static function (int $id) use ($targetLang, $sourceLang, &$translations): int { + if ($id <= 0) { + return $id; + } + if (isset($translations[$id])) { + return $translations[$id]; + } + $translated = self::ensureTranslation($id, $targetLang, $sourceLang); + $translations[$id] = $translated; + + return $translated; + }; - foreach ($translations as $oldId => $newId) { + $newContent = self::walkContent($content, $resolve, $translations); + + return ['content' => $newContent, 'translations' => $translations]; + } + + public static function rewireContentForLanguage(string $content, string $postLang): array + { + if ($content === '' || !self::isMediaTranslated()) { + return ['content' => $content, 'rewires' => []]; + } + + $rewires = []; + $resolve = static function (int $id) use ($postLang, &$rewires): int { + if ($id <= 0) { + return $id; + } + if (isset($rewires[$id])) { + return $rewires[$id]; + } + $rewired = self::rewireForLanguage($id, $postLang); + $rewires[$id] = $rewired; + + return $rewired; + }; + + $newContent = self::walkContent($content, $resolve, $rewires); + + return ['content' => $newContent, 'rewires' => $rewires]; + } + + private static function walkContent(string $content, callable $resolve, array $idMap): string + { + $blocks = parse_blocks($content); + $newBlocks = self::walkBlocks($blocks, $resolve); + $newContent = serialize_blocks($newBlocks); + + foreach ($idMap as $oldId => $newId) { if ($oldId !== $newId) { $newContent = str_replace("wp-image-{$oldId}", "wp-image-{$newId}", $newContent); } } - return ['content' => $newContent, 'translations' => $translations]; + return $newContent; } - private static function walkBlocks(array $blocks, string $targetLang, string $sourceLang, array &$translations): array + private static function walkBlocks(array $blocks, callable $resolve): array { foreach ($blocks as &$block) { $name = $block['blockName'] ?? null; $attrs = $block['attrs'] ?? []; if (in_array($name, ['core/image', 'core/cover', 'core/media-text'], true) && !empty($attrs['id'])) { - $attrs['id'] = self::resolve((int) $attrs['id'], $targetLang, $sourceLang, $translations); + $attrs['id'] = $resolve((int) $attrs['id']); } if ($name === 'core/gallery' && !empty($attrs['ids']) && is_array($attrs['ids'])) { - $attrs['ids'] = array_map( - fn ($id) => self::resolve((int) $id, $targetLang, $sourceLang, $translations), - $attrs['ids'] - ); + $attrs['ids'] = array_map(static fn ($id) => $resolve((int) $id), $attrs['ids']); } $block['attrs'] = $attrs; if (!empty($block['innerBlocks'])) { - $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $targetLang, $sourceLang, $translations); + $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $resolve); } } return $blocks; } - private static function resolve(int $sourceId, string $targetLang, string $sourceLang, array &$translations): int + private static function duplicateAttachment(WP_Post $source, string $targetLang): int { - if ($sourceId <= 0) { - return $sourceId; + $newId = wp_insert_post([ + 'post_type' => 'attachment', + 'post_title' => $source->post_title, + 'post_content' => $source->post_content, + 'post_excerpt' => $source->post_excerpt, + 'post_mime_type' => $source->post_mime_type, + 'post_status' => 'inherit', + 'post_parent' => 0, + 'guid' => $source->guid, + ], true); + + if (is_wp_error($newId) || !$newId) { + return (int) $source->ID; } - if (isset($translations[$sourceId])) { - return $translations[$sourceId]; + + foreach (self::ATTACHMENT_META_KEYS as $metaKey) { + $value = get_post_meta($source->ID, $metaKey, true); + if ($value !== '' && $value !== false && $value !== null) { + update_post_meta($newId, $metaKey, $value); + } } - $translated = self::ensureTranslation($sourceId, $targetLang, $sourceLang); - $translations[$sourceId] = $translated; + pll_set_post_language($newId, $targetLang); + + $group = pll_get_post_translations($source->ID); + $group[$targetLang] = $newId; + pll_save_post_translations($group); - return $translated; + return (int) $newId; } } diff --git a/src/Features/Mcp/Rewire.php b/src/Features/Mcp/Rewire.php new file mode 100644 index 0000000..003cfc1 --- /dev/null +++ b/src/Features/Mcp/Rewire.php @@ -0,0 +1,141 @@ + __('Rewire attachments to post language', 'spoko-enhanced-rest-api'), + 'description' => __('Backfill an existing post or page so featured image and Gutenberg image/gallery/cover/media-text block attachment IDs match the post\'s Polylang language. Untagged attachments are assigned the post\'s language in place; cross-language attachments are swapped for the matching language translation (created on demand, sharing the underlying file). Use this on posts that were created before posts-translate existed.', 'spoko-enhanced-rest-api'), + 'category' => 'spoko-content', + 'input_schema' => self::inputSchema(), + 'output_schema' => self::outputSchema(), + 'permission_callback' => [self::class, 'canRewire'], + 'execute_callback' => [self::class, 'execute'], + 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]], + ]); + } + + public static function canRewire(array $input): bool|WP_Error + { + $id = (int) ($input['post_id'] ?? 0); + if (!in_array(get_post_type($id), self::ALLOWED_POST_TYPES, true)) { + return new WP_Error('not_supported', __('Target must be a post or page.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + if (!current_user_can('edit_post', $id)) { + return new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]); + } + + return true; + } + + public static function execute(array $input): array|WP_Error + { + if (!function_exists('pll_get_post_language')) { + return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api')); + } + + $postId = (int) $input['post_id']; + $post = get_post($postId); + if (!$post) { + return new WP_Error('not_found', __('Post does not exist.', 'spoko-enhanced-rest-api'), ['status' => 404]); + } + + $postLang = (string) pll_get_post_language($postId); + if ($postLang === '') { + return new WP_Error('no_language', __('Post has no Polylang language assigned.', 'spoko-enhanced-rest-api'), ['status' => 400]); + } + + if (!MediaTranslator::isMediaTranslated()) { + return [ + 'post_updated' => false, + 'featured_rewire' => null, + 'content_rewires' => [], + 'note' => 'Polylang media translations are disabled; nothing to rewire.', + ]; + } + + $featuredRewire = null; + $oldThumb = (int) get_post_thumbnail_id($postId); + if ($oldThumb > 0) { + $newThumb = MediaTranslator::rewireForLanguage($oldThumb, $postLang); + if ($newThumb !== $oldThumb) { + set_post_thumbnail($postId, $newThumb); + $featuredRewire = ['old_id' => $oldThumb, 'new_id' => $newThumb]; + } + } + + $contentResult = MediaTranslator::rewireContentForLanguage($post->post_content, $postLang); + $contentRewires = []; + foreach ($contentResult['rewires'] as $oldId => $newId) { + if ((int) $oldId !== (int) $newId) { + $contentRewires[] = ['old_id' => (int) $oldId, 'new_id' => (int) $newId]; + } + } + + $postUpdated = false; + if ($contentResult['content'] !== $post->post_content) { + $update = wp_update_post([ + 'ID' => $postId, + 'post_content' => $contentResult['content'], + ], true); + if (is_wp_error($update)) { + return $update; + } + $postUpdated = true; + } + + return [ + 'post_updated' => $postUpdated, + 'featured_rewire' => $featuredRewire, + 'content_rewires' => $contentRewires, + ]; + } + + private static function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['post_id'], + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Post or page ID to rewire.'], + ], + ]; + } + + private static function outputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_updated' => ['type' => 'boolean', 'description' => 'True if post_content was rewritten.'], + 'featured_rewire' => [ + 'type' => ['object', 'null'], + 'properties' => [ + 'old_id' => ['type' => 'integer'], + 'new_id' => ['type' => 'integer'], + ], + ], + 'content_rewires' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'old_id' => ['type' => 'integer'], + 'new_id' => ['type' => 'integer'], + ], + ], + ], + 'note' => ['type' => 'string', 'description' => 'Present only when no rewire was possible (e.g. media translations disabled).'], + ], + ]; + } +}