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..4b4ba7b --- /dev/null +++ b/src/Features/Mcp/AbilitiesFeature.php @@ -0,0 +1,58 @@ + __('SPOKO content workflows', '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'), + ] + ); + } + + public function registerAbilities(): void + { + if (!function_exists('wp_register_ability')) { + return; + } + + Read::register(); + Posts::register(); + Pages::register(); + Terms::register(); + + 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 new file mode 100644 index 0000000..ba030e6 --- /dev/null +++ b/src/Features/Mcp/MediaTranslator.php @@ -0,0 +1,197 @@ +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); + } + + return self::duplicateAttachment($source, $targetLang); + } + + public static function rewireForLanguage(int $attachmentId, string $postLang): int + { + if (!self::isMediaTranslated() || $attachmentId <= 0) { + return $attachmentId; + } + + $source = get_post($attachmentId); + if (!$source || $source->post_type !== 'attachment') { + return $attachmentId; + } + + $currentLang = (string) pll_get_post_language($attachmentId); + + if ($currentLang === $postLang) { + return $attachmentId; + } + + if ($currentLang === '') { + pll_set_post_language($attachmentId, $postLang); + return $attachmentId; + } + + $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 + { + if ($content === '' || !self::isMediaTranslated()) { + return ['content' => $content, 'translations' => []]; + } + + $translations = []; + $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; + }; + + $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 $newContent; + } + + 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'] = $resolve((int) $attrs['id']); + } + + if ($name === 'core/gallery' && !empty($attrs['ids']) && is_array($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'], $resolve); + } + } + + return $blocks; + } + + private static function duplicateAttachment(WP_Post $source, string $targetLang): int + { + $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; + } + + 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); + } + } + + pll_set_post_language($newId, $targetLang); + + $group = pll_get_post_translations($source->ID); + $group[$targetLang] = $newId; + pll_save_post_translations($group); + + return (int) $newId; + } +} diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php new file mode 100644 index 0000000..73a3af8 --- /dev/null +++ b/src/Features/Mcp/Pages.php @@ -0,0 +1,307 @@ + __('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 + { + $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'], + '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'])) { + $parentId = (int) $input['parent_id']; + if ($error = self::validateParent($parentId)) { + return $error; + } + $args['post_parent'] = $parentId; + } + + $id = wp_insert_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + if ($featuredId !== null) { + set_post_thumbnail($id, $featuredId); + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + + 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)) { + $parentId = (int) $input['parent_id']; + if ($parentId !== 0 && ($error = self::validateParent($parentId))) { + return $error; + } + $args['post_parent'] = $parentId; + } + + $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 = (int) $input['id']; + if (count($args) > 1) { + $result = wp_update_post($args, true); + if (is_wp_error($result)) { + return $result; + } + $id = (int) $result; + } + + 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 + { + $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' => $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') { + 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 [ + '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], + 'featured_image_id' => ['type' => 'integer', 'minimum' => 1], + 'seo' => SeoMeta::inputSchema(), + ], + ]; + } + + 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], + 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'], + 'seo' => SeoMeta::inputSchema(), + ], + ]; + } + + 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..31e2356 --- /dev/null +++ b/src/Features/Mcp/Posts.php @@ -0,0 +1,338 @@ + __('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 + { + $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; + } + + $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'], + '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 ($catIds) { + $args['post_category'] = $catIds; + } + + $id = wp_insert_post($args, true); + if (is_wp_error($id)) { + return $id; + } + + if ($tagIds) { + wp_set_post_tags($id, $tagIds); + } + + if ($featuredId !== null) { + set_post_thumbnail($id, $featuredId); + } + + if ($seo) { + SeoMeta::write($id, $seo); + } + + 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)) { + $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; + } + } + + $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]); + } + + $id = (int) $input['id']; + if (count($args) > 1) { + $result = wp_update_post($args, true); + if (is_wp_error($result)) { + return $result; + } + $id = (int) $result; + } + + if ($hasTagUpdate) { + 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]; + } + + public static function delete(array $input): array|WP_Error + { + $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' => $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) { + 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 + { + 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']], + 'featured_image_id' => ['type' => 'integer', 'minimum' => 1], + 'seo' => SeoMeta::inputSchema(), + ], + ]; + } + + 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']], + 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'], + 'seo' => SeoMeta::inputSchema(), + ], + ]; + } + + 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/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/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).'], + ], + ]; + } +} 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, + ]; + } +} diff --git a/src/Features/Mcp/Terms.php b/src/Features/Mcp/Terms.php new file mode 100644 index 0000000..dcdb3c0 --- /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 = get_taxonomy($tax)->cap->manage_terms; + + 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/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'], + ], + ], + ], + ], + ]; + } +} diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php new file mode 100644 index 0000000..4ef63c9 --- /dev/null +++ b/src/Features/Mcp/Translations.php @@ -0,0 +1,189 @@ + __('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; + } + + 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)) { + 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 ? array_map('intval', reset($distinctGroups)) : []; + foreach ($inputMap as $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, + $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] + ); + } + } + + 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]; + } +}