Files
WP-LIT-MCP/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php
T
janniclas.loosenandClaude Opus 5.5 251934ce53 feat: deferred 1a items - menu_rev lock, lit_mcp_guides filter, warnings, schedule guard precision
Task 14 (rulings P17/P18, Q10):
- lit/menu-get returns menu_rev (Rev::ofMenu over id, parent, position,
  title, url, object, object_id); lit/menu-set-items requires it, refuses
  a stale one with rev_mismatch (hint: lit/menu-get) and returns the new
  menu_rev. Errors::revMismatch takes an optional message/hint.
- GuideRepository: filter lit_mcp_guides adds/overrides topics; invalid
  topic names and non-string content are ignored, files stay the default.
- lit/post-trash names front page, posts page and linking menus in the
  confirmation and as warnings; lit/menu-assign-location needs confirm
  to remove an assigned menu and names it; lit/post-schedule warns that
  a private entry becomes public; Divi hints no longer point to
  non-existing lit-divi-* tools.
- PostGuard::missedSchedule: 60 s publish window, zero GMT date falls
  back to get_gmt_from_date(post_date); post-schedule refuses dates
  within that window.
- write_failed after a clean rollback that keeps a revision/backup says
  "Die Aenderung wurde nicht gespeichert (... bleibt erhalten)" via the
  new state parameter (Errors::writeFailed / WriteFailed rolledBackState).
- wp-basis-smoke: menu_rev flow, unassign confirm, trash/menu and
  private-schedule warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 04:33:15 +02:00

164 lines
6.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Revisions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostChangeResult;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/revision-restore: restores title, content and excerpt of an entry from one of
* its own revisions; the status is not touched. Because this overwrites the current
* content, rev of the current entry (from lit/content-get) is required (optimistic
* lock, spec §2 principle 7) – the restore is itself a write. Before restoring, the
* current state is saved as a revision (RevisionPoint::capture), so the restore is
* undoable via another lit/revision-restore call. A revision id that does not belong
* to a manageable entry (wrong post_type, or post_parent 0 – an orphaned revision) is
* rejected as a validation error on revision_id, the only id field this tool takes.
*/
final class RevisionRestoreAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/revision-restore';
}
public function label(): string
{
return 'Revision wiederherstellen';
}
public function description(): string
{
return 'Stellt Titel, Inhalt und Auszug eines Eintrags aus einer Revision wieder her (Status bleibt). '
. 'Der aktuelle Stand wird vorher als Revision gesichert (revision_id im Ergebnis). rev des Eintrags '
. 'aus lit-content-get ist Pflicht, damit parallele Änderungen nicht überschrieben werden. Erst nach '
. 'Rückfrage mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'revision_id' => ['type' => 'integer', 'minimum' => 1],
'rev' => [
'type' => 'string',
'description' => 'rev des Eintrags aus lit-content-get (optimistische Sperre).',
],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['revision_id', 'rev'],
'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
{
$pair = $this->load($input);
return $pair instanceof WP_Error ? $pair : true;
}
public function execute(array $input): mixed
{
$pair = $this->load($input);
if ($pair instanceof WP_Error) {
return $pair;
}
[$revision, $post] = $pair;
$postId = (int) $post->ID;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf(
'"%s" (ID %d) auf den Stand vom %s zurücksetzen?',
$post->post_title,
$postId,
$revision->post_date
));
}
if (! Rev::matches($input, $post)) {
return Errors::revMismatch();
}
// wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule.
$unstable = PostGuard::statusStable($post, 'revision_id');
if ($unstable !== null) {
return $unstable;
}
$backupId = RevisionPoint::capture($postId);
$restored = wp_restore_post_revision((int) $revision->ID);
if (! $restored) {
return Errors::writeFailed(
'WordPress konnte die Revision nicht wiederherstellen.',
[],
null,
$backupId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $backupId))
);
}
// The restore is saved – failures from here on are logged and reported as warnings only.
$change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Wiederherstellen');
$updated = $change->reread($post) ?? $post;
$summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date);
$postType = (string) $post->post_type;
$change->log($postType, $summary, $backupId);
return $change->result($summary . sprintf(' (Status unverändert: %s).', $updated->post_status), $updated)
->changed((string) $postId, $postType, 'restored', (string) $updated->post_title)
->revisionId($backupId)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->toArray();
}
/**
* @param array<string, mixed> $input
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
*/
private function load(array $input): array|WP_Error
{
$revisionId = PostGuard::id($input['revision_id'] ?? null);
$revision = $revisionId === null ? null : get_post($revisionId);
if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') {
return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit/revisions-list abrufen.');
}
// field 'revision_id': post_parent 0 (an orphaned revision) is the only id this
// input carries, so a validation error must name revision_id, not post_id.
$post = PostGuard::editable((int) $revision->post_parent, $this->schema, 'revision_id');
return $post instanceof WP_Error ? $post : [$revision, $post];
}
}