Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions docs/ABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ Category: `editorial-write`.
| --- | --- |
| `create-editorial-draft` | Create a draft from title, content, categories, tags, media, SEO |
| `update-editorial-draft` | Update an existing draft or pending post |
| `attach-images-to-draft` | Attach media IDs or sideload image URLs to a post |
| `attach-images-to-draft` | Attach media IDs or sideload publicly accessible image URLs to a post |
| `set-featured-image` | Set an attachment as the featured image |
| `upload-media-base64` | Upload a file from a base64 payload to the media library |
| `upload-media-base64` | Upload a small file from a base64 payload (fallback for tiny payloads only) |
| `upload-media-file` | Upload a file from a local file path (preferred for editor-attached images) |
| `suggest-internal-links` | Suggest existing posts to link from a draft |
| `optimize-seo-metadata` | Write Yoast and/or Rank Math SEO metadata |
| `schedule-post` | Schedule a post for future publication |
Expand All @@ -37,7 +38,7 @@ Category: `editorial-write`.

- Read abilities: `edit_posts`.
- Draft creation: `edit_posts`.
- Media upload (`upload-media-base64`): `upload_files`.
- Media upload (`upload-media-base64`, `upload-media-file`): `upload_files`.
- Edit abilities: `edit_post` on the target post ID.
- Publish / schedule: `edit_post` + `publish_posts` on the target post ID.

Expand All @@ -57,3 +58,20 @@ Category: `editorial-write`.
| `both` | Write both sets of meta fields |

Supported fields include SEO title, meta description, focus keywords, canonical URL, and Open Graph / Twitter metadata.

## Image upload paths

Three abilities cover different image sources. Choose based on where the image lives — do not base64-encode local files inside tool calls.

| Scenario | Ability | Input |
| --- | --- | --- |
| Image already on the public web | `attach-images-to-draft` | `image_urls` |
| Local file (editor pasted or attached in chat) | `upload-media-file` | `file_path` |
| Tiny payload already available as base64 | `upload-media-base64` | `base64_data` |

Typical flow for a local editor image:

1. `upload-media-file` with `file_path` (and optional `alt_text` / `caption`) → returns `media_id`.
2. `set-featured-image` or `attach-images-to-draft` with that `media_id`.

**Remote WordPress + local MCP client:** `file_path` must be readable where the ability callback runs. When WordPress is remote and the file is on the editor's machine, the MCP proxy must intercept `upload-media-file` and upload the binary directly (e.g. to `/wp-json/wp/v2/media`) before attaching. See [issue #1](https://github.com/BlackBoxVision/wp-editorial-abilities/issues/1).
20 changes: 15 additions & 5 deletions includes/Abilities/AbilityRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ private function registerWriteAbilities(): void

$this->ability('attach-images-to-draft', [
'label' => __('Attach Images to Draft', 'wp-editorial-abilities'),
'description' => __('Attach existing media IDs or sideload image URLs to an editable post.', 'wp-editorial-abilities'),
'description' => __(
'Attach existing media IDs or sideload publicly accessible image URLs to an editable post. Use image_urls when the image is already hosted on the internet (e.g. the featured image from a source article). Do NOT use image_urls for files the editor pasted or uploaded locally in the chat — use upload-media-file instead.',
'wp-editorial-abilities'
),
'category' => 'editorial-write',
'input_schema' => $this->objectSchema([
'post_id' => ['type' => 'integer', 'required' => true],
Expand Down Expand Up @@ -201,7 +204,10 @@ private function registerWriteAbilities(): void

$this->ability('upload-media-base64', [
'label' => __('Upload Media From Base64', 'wp-editorial-abilities'),
'description' => __('Upload a file (typically an image the editor shared directly in chat) to the media library from a base64-encoded payload. Returns the attachment ID and URL so it can be used as a featured image or attached to a draft.', 'wp-editorial-abilities'),
'description' => __(
'Upload a small file to the media library from an already-encoded base64 payload. Fallback only — prefer upload-media-file for local files and attach-images-to-draft with image_urls for public URLs. Do NOT base64-encode large local files inside a tool call.',
'wp-editorial-abilities'
),
'category' => 'editorial-write',
'input_schema' => $this->objectSchema([
'filename' => ['type' => 'string', 'required' => true, 'description' => 'Filename including extension, e.g. cover.png.'],
Expand All @@ -216,11 +222,15 @@ private function registerWriteAbilities(): void

$this->ability('upload-media-file', [
'label' => __('Upload Media From File Path', 'wp-editorial-abilities'),
'description' => __('Upload a file from a local file path to the media library. Use this when you have a file on disk. Returns the attachment ID and URL so it can be used as a featured image or attached to a draft.', 'wp-editorial-abilities'),
'description' => __(
'Upload a file from a local file path to the media library. Use this when the image is a local file with no public URL (e.g. one the editor pasted or uploaded directly in the conversation). Returns the attachment ID and URL. Do NOT use this for images that already have a public URL (use attach-images-to-draft with image_urls instead), and do NOT try to base64-encode the file for this ability.',
'wp-editorial-abilities'
),
'category' => 'editorial-write',
'input_schema' => $this->objectSchema([
'file_path' => ['type' => 'string', 'required' => true, 'description' => 'Absolute or relative file path to upload, e.g. /tmp/image.png or ./uploads/photo.jpg'],
'description' => ['type' => 'string', 'description' => 'Optional caption / alt text.'],
'file_path' => ['type' => 'string', 'required' => true, 'description' => 'Absolute or relative file path accessible from wherever this ability executes, e.g. /tmp/image.png.'],
'alt_text' => ['type' => 'string'],
'caption' => ['type' => 'string'],
], ['file_path']),
'output_schema' => $this->objectSchema(),
'execute_callback' => [$this->media, 'uploadMediaFromFile'],
Expand Down
53 changes: 30 additions & 23 deletions includes/Services/MediaService.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,33 +127,41 @@ public function uploadMediaFromFile(array $input): array|WP_Error
require_once ABSPATH . 'wp-admin/includes/image.php';

$file_path = isset($input['file_path']) ? (string) $input['file_path'] : '';
$description = isset($input['description']) ? sanitize_text_field((string) $input['description']) : '';
$alt_text = isset($input['alt_text']) ? sanitize_text_field((string) $input['alt_text']) : '';
$caption = isset($input['caption']) ? sanitize_text_field((string) $input['caption']) : '';

if ($file_path === '') {
return new WP_Error('wpea_missing_file_path', __('file_path is required.', 'wp-editorial-abilities'));
if ($alt_text === '' && $caption === '' && isset($input['description'])) {
$fallback = sanitize_text_field((string) $input['description']);
$alt_text = $fallback;
$caption = $fallback;
}

// Validar que el archivo existe
if (! file_exists($file_path)) {
return new WP_Error('wpea_file_not_found', sprintf(__('File not found: %s', 'wp-editorial-abilities'), $file_path));
if ($file_path === '' || ! is_readable($file_path)) {
return new WP_Error(
'wpea_file_not_readable',
sprintf(
/* translators: %s: file path */
__('Could not read file at "%s". This ability requires the file to be accessible from the environment where WordPress abilities execute — see the architecture note in this issue if the file lives on the MCP client instead.', 'wp-editorial-abilities'),
$file_path
)
);
}

// Validar que es readable
if (! is_readable($file_path)) {
return new WP_Error('wpea_file_not_readable', sprintf(__('File is not readable: %s', 'wp-editorial-abilities'), $file_path));
}
$filename = sanitize_file_name(basename($file_path));
$contents = file_get_contents($file_path);

// Obtener el contenido del archivo
$file_contents = file_get_contents($file_path);
if ($file_contents === false) {
return new WP_Error('wpea_file_read_error', sprintf(__('Could not read file: %s', 'wp-editorial-abilities'), $file_path));
if ($contents === false) {
return new WP_Error(
'wpea_file_not_readable',
sprintf(
/* translators: %s: file path */
__('Could not read file at "%s". This ability requires the file to be accessible from the environment where WordPress abilities execute — see the architecture note in this issue if the file lives on the MCP client instead.', 'wp-editorial-abilities'),
$file_path
)
);
}

// Usar filename original si es posible
$filename = basename($file_path);

// Usar wp_upload_bits igual que base64
$upload = wp_upload_bits($filename, null, $file_contents);
$upload = wp_upload_bits($filename, null, $contents);

if (! empty($upload['error'])) {
return new WP_Error('wpea_upload_failed', (string) $upload['error']);
Expand All @@ -166,8 +174,7 @@ public function uploadMediaFromFile(array $input): array|WP_Error
'guid' => $upload['url'],
'post_mime_type' => $filetype['type'],
'post_title' => sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)),
'post_content' => $description,
'post_excerpt' => $description,
'post_excerpt' => $caption,
'post_status' => 'inherit',
], $file, 0, true);

Expand All @@ -178,8 +185,8 @@ public function uploadMediaFromFile(array $input): array|WP_Error
$metadata = wp_generate_attachment_metadata($attachment_id, $file);
wp_update_attachment_metadata($attachment_id, $metadata);

if ($description !== '') {
update_post_meta($attachment_id, '_wp_attachment_image_alt', $description);
if ($alt_text !== '') {
update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
}

$attachment = get_post($attachment_id);
Expand Down
75 changes: 75 additions & 0 deletions tests/upload-media-file.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

/**
* Integration checks for MediaService::uploadMediaFromFile().
*
* Run inside wp-env:
* npx @wordpress/env run cli wp eval-file wp-content/plugins/wp-editorial-abilities/tests/upload-media-file.php
*/

use WpEditorialAbilities\Services\MediaService;

if (! defined('ABSPATH')) {
fwrite(STDERR, "Run this script through WP-CLI inside WordPress.\n");
exit(1);
}

$media = new MediaService();
$failures = 0;

$assert = static function (bool $condition, string $message) use (&$failures): void {
if (! $condition) {
fwrite(STDERR, "FAIL: {$message}\n");
++$failures;
}
};

$missing = $media->uploadMediaFromFile(['file_path' => '/tmp/wpea-does-not-exist-' . wp_generate_password(8, false) . '.png']);
$assert(is_wp_error($missing), 'unreadable path should return WP_Error');
$assert(
is_wp_error($missing) && $missing->get_error_code() === 'wpea_file_not_readable',
'unreadable path should use wpea_file_not_readable'
);

$tmp = wp_tempnam('wpea-upload-test');
if ($tmp === '') {
fwrite(STDERR, "FAIL: could not create temp file\n");
exit(1);
}

$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', true);
file_put_contents($tmp, $png);

$uploaded = $media->uploadMediaFromFile([
'file_path' => $tmp,
'alt_text' => 'Integration alt text',
'caption' => 'Integration caption',
]);

@unlink($tmp);

$assert(! is_wp_error($uploaded), 'readable file should upload successfully');
$assert(is_array($uploaded) && ! empty($uploaded['media_id']), 'upload should return media_id');
$assert(is_array($uploaded) && ! empty($uploaded['url']), 'upload should return url');

if (is_array($uploaded) && ! empty($uploaded['media_id'])) {
$alt = get_post_meta((int) $uploaded['media_id'], '_wp_attachment_image_alt', true);
$assert($alt === 'Integration alt text', 'alt_text should be stored on attachment');

$attachment = get_post((int) $uploaded['media_id']);
$assert($attachment instanceof WP_Post, 'attachment post should exist');
if ($attachment instanceof WP_Post) {
$assert($attachment->post_excerpt === 'Integration caption', 'caption should be stored on attachment');
}

wp_delete_attachment((int) $uploaded['media_id'], true);
}

if ($failures > 0) {
fwrite(STDERR, "{$failures} assertion(s) failed.\n");
exit(1);
}

echo "PASS: upload-media-file integration checks\n";
4 changes: 2 additions & 2 deletions wp-editorial-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: WP Editorial Abilities
* Description: Editorial workflow abilities for WordPress Abilities API and MCP Adapter.
* Version: 0.1.0
* Version: 0.2.0
* Requires at least: 6.9
* Requires PHP: 8.0
* Author: BlackBox Vision
Expand All @@ -18,7 +18,7 @@

define('WPEA_PLUGIN_FILE', __FILE__);
define('WPEA_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WPEA_PLUGIN_VERSION', '0.1.0');
define('WPEA_PLUGIN_VERSION', '0.2.0');

spl_autoload_register(static function (string $class): void {
$prefix = 'WpEditorialAbilities\\';
Expand Down