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:
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
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-publish: publishes an entry immediately. A scheduled ("future") or
|
||||
* future-dated entry is republished now instead of staying in the future (spec §6.2);
|
||||
* an already-published entry is a no-op.
|
||||
*/
|
||||
final class PostPublishAbility implements Ability
|
||||
{
|
||||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/post-publish';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Veröffentlichen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Veröffentlicht einen Eintrag sofort; geplante oder zukunftsdatierte Einträge erhalten das '
|
||||
. 'aktuelle Datum. Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['post_id'],
|
||||
'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 true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$postId = (int) $post->ID;
|
||||
$title = (string) $post->post_title;
|
||||
$editUrl = (string) get_edit_post_link($postId, 'raw');
|
||||
if ($post->post_status === 'publish') {
|
||||
return ToolResult::ok(sprintf('"%s" ist bereits veröffentlicht.', $title))
|
||||
->rev(Rev::ofPost($post))
|
||||
->urls((string) get_permalink($postId), $editUrl)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$data = ['ID' => $postId, 'post_status' => 'publish'];
|
||||
if ($post->post_status === 'future' || $this->isFutureDated($post)) {
|
||||
$data['post_date'] = current_time('mysql');
|
||||
$data['post_date_gmt'] = current_time('mysql', true);
|
||||
$data['edit_date'] = true;
|
||||
}
|
||||
|
||||
$result = wp_update_post($data, true);
|
||||
if ($result instanceof WP_Error) {
|
||||
return Errors::invalidInput(
|
||||
'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message()
|
||||
);
|
||||
}
|
||||
|
||||
$status = get_post_status($postId);
|
||||
if ($status !== 'publish') {
|
||||
return Errors::unsupported(
|
||||
sprintf('Nach dem Veröffentlichen ist der Status "%s".', (string) $status),
|
||||
'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 Veröffentlichen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
$summary = sprintf('"%s" veröffentlicht', $title);
|
||||
$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(sprintf('"%s" ist jetzt veröffentlicht.', $title))
|
||||
->changed((string) $postId, (string) $post->post_type, 'published', $title)
|
||||
->rev(Rev::ofPost($updated))
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_permalink($postId), $editUrl)
|
||||
->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.');
|
||||
}
|
||||
|
||||
private function isFutureDated(WP_Post $post): bool
|
||||
{
|
||||
$gmt = (string) $post->post_date_gmt;
|
||||
|
||||
return $gmt !== '' && $gmt !== '0000-00-00 00:00:00' && strtotime($gmt . ' UTC') > time();
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
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-trash: moves an entry to the trash – never a permanent delete. When the
|
||||
* trash itself is disabled (EMPTY_TRASH_DAYS = 0), wp_trash_post() would delete the
|
||||
* entry permanently (wp-includes/post.php: `if ( ! EMPTY_TRASH_DAYS ) return
|
||||
* wp_delete_post( $post_id, true );`), so this tool refuses instead of calling it.
|
||||
*/
|
||||
final class PostTrashAbility implements Ability
|
||||
{
|
||||
/** @var callable(): bool */
|
||||
private $trashEnabled;
|
||||
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private ChangeLog $log,
|
||||
?callable $trashEnabled = null
|
||||
) {
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/post-trash';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'In den Papierkorb';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt einen Eintrag in den Papierkorb (nie endgültig löschen; wiederherstellbar in WordPress). '
|
||||
. 'Erst nach Rückfrage beim Nutzer mit confirm: true aufrufen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
|
||||
],
|
||||
'required' => ['post_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => true, '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;
|
||||
}
|
||||
|
||||
$postId = (int) $post->ID;
|
||||
$title = (string) $post->post_title;
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(sprintf('"%s" (ID %d) in den Papierkorb legen?', $title, $postId));
|
||||
}
|
||||
|
||||
if ($post->post_status === 'trash') {
|
||||
return ToolResult::ok(sprintf('"%s" liegt bereits im Papierkorb.', $title))
|
||||
->rev(Rev::ofPost($post))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
'Der Papierkorb ist auf dieser Website deaktiviert (EMPTY_TRASH_DAYS = 0); '
|
||||
. 'WordPress würde endgültig löschen. Nichts wurde geändert.',
|
||||
'Endgültiges Löschen ist über LIT MCP nicht möglich; der Nutzer kann es in WordPress selbst tun.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! wp_trash_post($postId)) {
|
||||
return Errors::unsupported(
|
||||
'WordPress hat das Verschieben in den Papierkorb abgelehnt.',
|
||||
'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).'
|
||||
);
|
||||
}
|
||||
|
||||
// The change is saved – a failure of the change log from here on is a warning only.
|
||||
$summary = sprintf('"%s" in den Papierkorb gelegt', $title);
|
||||
$warnings = [];
|
||||
$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(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title))
|
||||
->changed((string) $postId, (string) $post->post_type, 'trashed', $title)
|
||||
->rev(Rev::ofPost($updated))
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->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('delete_post', (int) $post->ID)
|
||||
? $post
|
||||
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu löschen.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
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-unpublish: withdraws a published, scheduled or private entry back to draft.
|
||||
* An entry that is already not public (draft, pending, auto-draft) is a no-op.
|
||||
*/
|
||||
final class PostUnpublishAbility implements Ability
|
||||
{
|
||||
private const NOT_PUBLIC = ['draft', 'pending', 'auto-draft'];
|
||||
|
||||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/post-unpublish';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Zurückziehen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Setzt einen veröffentlichten, geplanten oder privaten Eintrag zurück auf Entwurf. '
|
||||
. 'Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['post_id'],
|
||||
'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 = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
||||
|
||||
return $post instanceof WP_Error ? $post : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
||||
if ($post instanceof WP_Error) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
$postId = (int) $post->ID;
|
||||
$title = (string) $post->post_title;
|
||||
$editUrl = (string) get_edit_post_link($postId, 'raw');
|
||||
if (in_array($post->post_status, self::NOT_PUBLIC, true)) {
|
||||
return ToolResult::ok(sprintf('"%s" ist nicht veröffentlicht (Status: %s).', $title, $post->post_status))
|
||||
->rev(Rev::ofPost($post))
|
||||
->urls((string) get_preview_post_link($post), $editUrl)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true);
|
||||
if ($result instanceof WP_Error) {
|
||||
return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message());
|
||||
}
|
||||
|
||||
// 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 Zurückziehen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
$summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status);
|
||||
$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, 'unpublished', $title)
|
||||
->rev(Rev::ofPost($updated))
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_preview_post_link($updated), $editUrl)
|
||||
->warnings($warnings)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
|
||||
|
||||
/**
|
||||
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
|
||||
@@ -48,6 +52,10 @@ final class WordPressIntegration implements Integration
|
||||
new ContentGetAbility($this->schema, $presenter),
|
||||
new ContentCreateAbility($this->schema, $writer, $this->log),
|
||||
new ContentUpdateAbility($this->schema, $writer, $this->log),
|
||||
new PostPublishAbility($this->schema, $this->log),
|
||||
new PostUnpublishAbility($this->schema, $this->log),
|
||||
new PostScheduleAbility($this->schema, $this->log),
|
||||
new PostTrashAbility($this->schema, $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class PostPublishAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(ChangeLog $log): PostPublishAbility
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
return new PostPublishAbility($schema, $log);
|
||||
}
|
||||
|
||||
private function stubPost(array $fields): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(array_merge(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team'], $fields)));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
|
||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||||
}
|
||||
|
||||
public function testPublishesADraft(): void
|
||||
{
|
||||
$this->stubPost(['post_status' => 'draft', 'post_date_gmt' => '0000-00-00 00:00:00']);
|
||||
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'publish'], true)->andReturn(5);
|
||||
Functions\when('get_post_status')->justReturn('publish');
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->with('lit/post-publish', 'page', '5', '"Team" veröffentlicht')->andReturn(9);
|
||||
|
||||
$result = $this->ability($log)->execute(['post_id' => 5]);
|
||||
|
||||
self::assertSame('published', $result['changed'][0]['action']);
|
||||
self::assertSame(9, $result['log_id']);
|
||||
self::assertSame('https://kunde.de/team/', $result['preview_url']);
|
||||
}
|
||||
|
||||
public function testScheduledPostIsPublishedNowInsteadOfStayingFuture(): void
|
||||
{
|
||||
$this->stubPost(['post_status' => 'future', 'post_date_gmt' => '2099-01-01 10:00:00']);
|
||||
Functions\when('current_time')->alias(static fn (string $type, $gmt = 0) => $gmt ? '2026-09-25 08:00:00' : '2026-09-25 10:00:00');
|
||||
Functions\expect('wp_update_post')->once()->with([
|
||||
'ID' => 5,
|
||||
'post_status' => 'publish',
|
||||
'post_date' => '2026-09-25 10:00:00',
|
||||
'post_date_gmt' => '2026-09-25 08:00:00',
|
||||
'edit_date' => true,
|
||||
], true)->andReturn(5);
|
||||
Functions\when('get_post_status')->justReturn('publish');
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->allows('record')->andReturn(1);
|
||||
|
||||
$this->ability($log)->execute(['post_id' => 5]);
|
||||
}
|
||||
|
||||
public function testAlreadyPublishedIsANoOp(): void
|
||||
{
|
||||
$this->stubPost(['post_status' => 'publish']);
|
||||
Functions\expect('wp_update_post')->never();
|
||||
|
||||
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
|
||||
|
||||
self::assertSame([], $result['changed']);
|
||||
self::assertStringContainsString('bereits veröffentlicht', $result['summary']);
|
||||
}
|
||||
|
||||
public function testPermissionNeedsPublishRights(): void
|
||||
{
|
||||
$this->stubPost(['post_status' => 'draft']);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'publish_post');
|
||||
|
||||
self::assertSame('lit_forbidden', $this->ability(Mockery::mock(ChangeLog::class))->permission(['post_id' => 5])->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use DateTimeZone;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class PostScheduleAbilityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_title' => 'News', 'post_status' => 'draft']));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('wp_timezone')->justReturn(new DateTimeZone('Europe/Berlin'));
|
||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
|
||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||||
}
|
||||
|
||||
private function ability(ChangeLog $log): PostScheduleAbility
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
return new PostScheduleAbility($schema, $log);
|
||||
}
|
||||
|
||||
public function testSchedulesWithLocalAndGmtDates(): void
|
||||
{
|
||||
Functions\expect('wp_update_post')->once()->with([
|
||||
'ID' => 5,
|
||||
'post_status' => 'future',
|
||||
'post_date' => '2099-06-01 09:30:00',
|
||||
'post_date_gmt' => '2099-06-01 07:30:00',
|
||||
'edit_date' => true,
|
||||
], true)->andReturn(5);
|
||||
Functions\when('get_post_status')->justReturn('future');
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->andReturn(4);
|
||||
|
||||
$result = $this->ability($log)->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']);
|
||||
|
||||
self::assertSame('scheduled', $result['changed'][0]['action']);
|
||||
self::assertStringContainsString('2099-06-01 09:30', $result['summary']);
|
||||
}
|
||||
|
||||
public function testPastAndImpossibleDatesAreRejected(): void
|
||||
{
|
||||
Functions\expect('wp_update_post')->never();
|
||||
$ability = $this->ability(Mockery::mock(ChangeLog::class));
|
||||
|
||||
$past = $ability->execute(['post_id' => 5, 'date' => '2020-01-01 10:00']);
|
||||
$impossible = $ability->execute(['post_id' => 5, 'date' => '2099-13-45 10:00']);
|
||||
|
||||
self::assertSame('lit_validation_failed', $past->get_error_code());
|
||||
self::assertStringContainsString('Zukunft', $past->get_error_message());
|
||||
self::assertSame('lit_validation_failed', $impossible->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class PostTrashAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(ChangeLog $log): PostTrashAbility
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'publish']));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
return new PostTrashAbility($schema, $log);
|
||||
}
|
||||
|
||||
public function testRequiresConfirmation(): void
|
||||
{
|
||||
Functions\expect('wp_trash_post')->never();
|
||||
|
||||
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
|
||||
|
||||
self::assertSame('lit_confirm_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testMovesToTrashNeverDeletes(): void
|
||||
{
|
||||
Functions\expect('wp_trash_post')->once()->with(5)->andReturn(new WP_Post(['ID' => 5]));
|
||||
Functions\expect('wp_delete_post')->never();
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->with('lit/post-trash', 'page', '5', '"Alt" in den Papierkorb gelegt')->andReturn(2);
|
||||
|
||||
$result = $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]);
|
||||
|
||||
self::assertSame('trashed', $result['changed'][0]['action']);
|
||||
self::assertStringContainsString('Papierkorb', $result['summary']);
|
||||
}
|
||||
|
||||
public function testRefusesWhenTheTrashIsDisabledInsteadOfDeleting(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\expect('wp_trash_post')->never();
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
$ability = new PostTrashAbility($schema, Mockery::mock(ChangeLog::class), static fn (): bool => false);
|
||||
|
||||
$result = $ability->execute(['post_id' => 5, 'confirm' => true]);
|
||||
|
||||
self::assertSame('lit_unsupported', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testIsDestructiveAndIndirect(): void
|
||||
{
|
||||
$ability = $this->ability(Mockery::mock(ChangeLog::class));
|
||||
|
||||
self::assertTrue($ability->annotations()['destructive']);
|
||||
self::assertFalse($ability->isDirect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class PostUnpublishAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(ChangeLog $log, string $status): PostUnpublishAbility
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => $status]));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
|
||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
return new PostUnpublishAbility($schema, $log);
|
||||
}
|
||||
|
||||
public function testPublishedPostBecomesDraft(): void
|
||||
{
|
||||
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'draft'], true)->andReturn(5);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->andReturn(3);
|
||||
|
||||
$result = $this->ability($log, 'publish')->execute(['post_id' => 5]);
|
||||
|
||||
self::assertSame('unpublished', $result['changed'][0]['action']);
|
||||
}
|
||||
|
||||
public function testDraftIsANoOp(): void
|
||||
{
|
||||
Functions\expect('wp_update_post')->never();
|
||||
|
||||
$result = $this->ability(Mockery::mock(ChangeLog::class), 'draft')->execute(['post_id' => 5]);
|
||||
|
||||
self::assertSame([], $result['changed']);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ final class WordPressIntegrationTest extends TestCase
|
||||
'lit/content-get',
|
||||
'lit/content-create',
|
||||
'lit/content-update',
|
||||
'lit/post-publish',
|
||||
'lit/post-unpublish',
|
||||
'lit/post-schedule',
|
||||
'lit/post-trash',
|
||||
];
|
||||
|
||||
public function testProvidesTheWordPressAbilitiesInOrder(): void
|
||||
|
||||
Reference in New Issue
Block a user