diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php new file mode 100644 index 0000000..af8c5ae --- /dev/null +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -0,0 +1,156 @@ + '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 (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($post)) { + return Errors::revMismatch(); + } + + $backupId = RevisionPoint::capture($postId); + $restored = wp_restore_post_revision((int) $revision->ID); + if (! $restored) { + return Errors::unsupported( + 'WordPress konnte die Revision nicht wiederherstellen.', + 'Revision in WordPress unter „Revisionen“ prüfen.' + ); + } + + do_action('lit_mcp_after_save', $postId, $this->name(), $input); + $updated = get_post($postId); + $updated = $updated instanceof WP_Post ? $updated : $post; + $summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date); + $postType = (string) $post->post_type; + $logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId); + + return ToolResult::ok($summary . sprintf(' (Status unverändert: %s).', $updated->post_status)) + ->changed((string) $postId, $postType, 'restored', (string) $updated->post_title) + ->rev(Rev::ofPost($updated)) + ->revisionId($backupId) + ->logId($logId) + ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) + ->toArray(); + } + + /** + * @param array $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]; + } +} diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php new file mode 100644 index 0000000..d94370e --- /dev/null +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -0,0 +1,137 @@ + 'object', + 'properties' => array_merge( + ['post_id' => ['type' => 'integer', 'minimum' => 1]], + Cursor::inputProperties() + ), + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'date' => ['type' => 'string'], + 'author' => ['type' => 'object'], + 'title' => ['type' => 'string'], + 'autosave' => ['type' => 'boolean'], + 'excerpt' => ['type' => 'string'], + ], + 'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt'], + ], + ], + 'next_cursor' => ['type' => ['string', 'null']], + ], + 'required' => ['post_id', 'items', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, '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; + } + + $offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $limit = Cursor::limit($input['limit'] ?? null); + $revisions = wp_get_post_revisions((int) $post->ID, ['posts_per_page' => $limit + 1, 'offset' => $offset]); + $page = Cursor::page(array_values(is_array($revisions) ? $revisions : []), $limit, $offset); + + return [ + 'post_id' => (int) $post->ID, + 'items' => array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']), + 'next_cursor' => $page['next_cursor'], + ]; + } + + /** @return array */ + private function item(WP_Post $revision): array + { + $text = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $revision->post_content))); + + return [ + 'id' => (int) $revision->ID, + 'date' => (string) $revision->post_date, + 'author' => [ + 'id' => (int) $revision->post_author, + 'name' => (string) get_the_author_meta('display_name', (int) $revision->post_author), + ], + 'title' => (string) $revision->post_title, + 'autosave' => (bool) wp_is_post_autosave($revision), + 'excerpt' => mb_substr($text, 0, 160), + ]; + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 7746bd1..7894a4c 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -13,6 +13,8 @@ use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility; use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; use LIT\MCP\Integrations\WordPress\Content\PostPresenter; use LIT\MCP\Integrations\WordPress\Content\PostWriter; +use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; +use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; @@ -62,6 +64,8 @@ final class WordPressIntegration implements Integration new PostTrashAbility($this->schema, $this->log), new PostDuplicateAbility($this->schema, $cloner, $this->log), new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), + new RevisionsListAbility($this->schema), + new RevisionRestoreAbility($this->schema, $this->log), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php new file mode 100644 index 0000000..bf9223e --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php @@ -0,0 +1,115 @@ + 5, + 'post_type' => 'page', + 'post_title' => 'Team', + 'post_status' => 'publish', + 'post_content' => 'alt', + ]; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5, 'post_date' => '2026-09-20 10:00:00']), + 5 => new WP_Post(self::CURRENT_POST), + default => null, + }); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(ChangeLog $log): RevisionRestoreAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new RevisionRestoreAbility($schema, $log); + } + + private function currentRev(): string + { + return Rev::ofPost(new WP_Post(self::CURRENT_POST)); + } + + public function testNonRevisionIdIsNotFound(): void + { + self::assertSame('lit_not_found', $this->ability(Mockery::mock(ChangeLog::class))->permission(['revision_id' => 5])->get_error_code()); + } + + public function testRequiresConfirmation(): void + { + Functions\expect('wp_restore_post_revision')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + } + + public function testOrphanRevisionYieldsValidationErrorOnRevisionIdField(): void + { + Functions\when('get_post')->alias(static fn (int $id) => $id === 810 + ? new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 0]) + : null); + + $error = $this->ability(Mockery::mock(ChangeLog::class)) + ->permission(['revision_id' => 810, 'rev' => 'irrelevant', 'confirm' => true]); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); + } + + public function testMissingOrStaleRevIsRevMismatch(): void + { + Functions\expect('wp_restore_post_revision')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class)) + ->execute(['revision_id' => 810, 'confirm' => true, 'rev' => 'stale']); + + self::assertSame('lit_rev_mismatch', $result->get_error_code()); + } + + public function testSavesTheCurrentStateThenRestores(): void + { + $order = []; + Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'backup'; + return 813; + }); + Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]); + Functions\expect('wp_restore_post_revision')->once()->with(810)->andReturnUsing(static function () use (&$order): int { + $order[] = 'restore'; + return 5; + }); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/revision-restore', 'page', '5', Mockery::type('string'), 813)->andReturn(12); + + $result = $this->ability($log)->execute([ + 'revision_id' => 810, + 'confirm' => true, + 'rev' => $this->currentRev(), + ]); + + self::assertSame(['backup', 'restore'], $order); + self::assertSame(813, $result['revision_id']); + self::assertSame('restored', $result['changed'][0]['action']); + self::assertStringContainsString('2026-09-20 10:00:00', $result['summary']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php new file mode 100644 index 0000000..ca8632f --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -0,0 +1,41 @@ +justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_the_author_meta')->justReturn('Jan'); + Functions\when('wp_is_post_autosave')->alias(static fn ($post): bool => $post->ID === 811); + Functions\when('wp_strip_all_tags')->alias(static fn (string $text): string => strip_tags($text)); + Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 3, 'offset' => 0])->andReturn([ + 812 => new WP_Post(['ID' => 812, 'post_title' => 'Team', 'post_content' => '

Neu

', 'post_author' => '2']), + 811 => new WP_Post(['ID' => 811, 'post_title' => 'Team', 'post_content' => '

Auto

', 'post_author' => '2']), + 810 => new WP_Post(['ID' => 810, 'post_title' => 'Team', 'post_content' => '

Alt

', 'post_author' => '2']), + ]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + $result = (new RevisionsListAbility($schema))->execute(['post_id' => 5, 'limit' => 2]); + + self::assertSame(5, $result['post_id']); + self::assertSame([812, 811], array_column($result['items'], 'id')); + self::assertSame('Neu', $result['items'][0]['excerpt']); + self::assertTrue($result['items'][1]['autosave']); + self::assertSame(['id' => 2, 'name' => 'Jan'], $result['items'][0]['author']); + self::assertSame(2, Cursor::decode($result['next_cursor'])); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 96413f3..3a0499c 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -26,6 +26,8 @@ final class WordPressIntegrationTest extends TestCase 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', + 'lit/revisions-list', + 'lit/revision-restore', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void