feat: explicit publish, unpublish, schedule and trash tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 23:18:35 +02:00
co-authored by Claude Opus 5.5
parent a9b2f2db71
commit 346619d6da
10 changed files with 902 additions and 0 deletions
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use DateTimeImmutable;
use DateTimeZone;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-schedule: schedules an entry for publication at a future point in time
* (site local time). An entry that is currently published stays live until then only
* if wp_update_post() keeps it that way; WordPress itself takes it offline once the
* status becomes "future".
*/
final class PostScheduleAbility implements Ability
{
private const FORMAT = 'Y-m-d H:i';
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-schedule';
}
public function label(): string
{
return 'Veröffentlichung planen';
}
public function description(): string
{
return 'Plant die Veröffentlichung eines Eintrags zu einem Zeitpunkt in der Zukunft (Ortszeit der '
. 'Website). Ein bereits veröffentlichter Eintrag ist bis dahin offline. Nur auf ausdrücklichen '
. 'Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'date' => [
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
'description' => 'JJJJ-MM-TT HH:MM in der Zeitzone der Website.',
],
],
'required' => ['post_id', 'date'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$raw = (string) ($input['date'] ?? '');
$date = DateTimeImmutable::createFromFormat('!' . self::FORMAT, $raw, wp_timezone());
if ($date === false || $date->format(self::FORMAT) !== $raw) {
return Errors::invalidInput('Ungültiges Datum.', ['date' => 'Format JJJJ-MM-TT HH:MM erwartet']);
}
if ($date->getTimestamp() <= time()) {
return Errors::invalidInput(
'Der Zeitpunkt muss in der Zukunft liegen.',
['date' => 'liegt nicht in der Zukunft'],
'Zum sofortigen Veröffentlichen lit-post-publish verwenden.'
);
}
$postId = (int) $post->ID;
$result = wp_update_post([
'ID' => $postId,
'post_status' => 'future',
'post_date' => $date->format('Y-m-d H:i:s'),
'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
'edit_date' => true,
], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message());
}
if (get_post_status($postId) !== 'future') {
return Errors::unsupported(
'WordPress hat den Eintrag nicht als geplant gespeichert.',
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Planen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$title = (string) $post->post_title;
$summary = sprintf('"%s" geplant für %s', $title, $raw)
. ($post->post_status === 'publish' ? ' (war veröffentlicht und ist bis dahin offline)' : '');
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'scheduled', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen oder zu planen.');
}
}