diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index af8c5ae..210d6c3 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Revisions; 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; @@ -113,25 +114,36 @@ final class RevisionRestoreAbility implements Ability $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.' - ); + return Errors::writeFailed('WordPress konnte die Revision nicht wiederherstellen.'); } - do_action('lit_mcp_after_save', $postId, $this->name(), $input); + // The restore 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 Wiederherstellen auf einen Fehler gestoßen; die Änderung ist gespeichert.', + $warnings + ); + $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); + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', + $warnings + ); 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) + ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) + ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php index d94370e..d31f4bc 100644 --- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Revisions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Cursor; +use LIT\MCP\Integrations\WordPress\Content\PostPresenter; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -13,7 +14,12 @@ use WP_Post; /** * lit/revisions-list: revisions of an entry, newest first. Autosaves are included - * (marked autosave: true) rather than filtered out, so the client can decide. + * (marked autosave: true) rather than filtered out, so the client can decide. Each + * item is marked divi (reusing PostPresenter::usesDivi() on the revision's own row, + * the same detection lit/content-get uses) because a Divi/block-built revision's + * post_content is shortcode or block-comment markup, not prose – excerpt is then a + * stripped fragment of that markup rather than a real preview; hints repeats + * PostPresenter::DIVI_HINT once when any item is flagged. */ final class RevisionsListAbility implements Ability { @@ -34,7 +40,8 @@ final class RevisionsListAbility implements Ability public function description(): string { return 'Listet die gespeicherten Revisionen eines Eintrags (neueste zuerst) mit Datum, Autor und ' - . 'Textauszug. Wiederherstellen mit lit-revision-restore.'; + . 'Textauszug. Revisionen von Divi-Inhalten sind mit divi: true markiert; ihr excerpt ist dann ' + . 'Shortcode-/Block-Markup, kein lesbarer Text. Wiederherstellen mit lit-revision-restore.'; } public function inputSchema(): array @@ -67,13 +74,15 @@ final class RevisionsListAbility implements Ability 'title' => ['type' => 'string'], 'autosave' => ['type' => 'boolean'], 'excerpt' => ['type' => 'string'], + 'divi' => ['type' => 'boolean'], ], - 'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt'], + 'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt', 'divi'], ], ], 'next_cursor' => ['type' => ['string', 'null']], + 'hints' => ['type' => 'array', 'items' => ['type' => 'string']], ], - 'required' => ['post_id', 'items', 'next_cursor'], + 'required' => ['post_id', 'items', 'next_cursor', 'hints'], ]; } @@ -109,11 +118,13 @@ final class RevisionsListAbility implements Ability $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); + $items = array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']); return [ 'post_id' => (int) $post->ID, - 'items' => array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']), + 'items' => $items, 'next_cursor' => $page['next_cursor'], + 'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::DIVI_HINT] : [], ]; } @@ -132,6 +143,7 @@ final class RevisionsListAbility implements Ability 'title' => (string) $revision->post_title, 'autosave' => (bool) wp_is_post_autosave($revision), 'excerpt' => mb_substr($text, 0, 160), + 'divi' => PostPresenter::usesDivi($revision), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php index bf9223e..d309937 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions; +use Brain\Monkey\Actions; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Rev; use LIT\MCP\ChangeLog\ChangeLog; @@ -11,6 +12,7 @@ use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class RevisionRestoreAbilityTest extends TestCase @@ -111,5 +113,56 @@ final class RevisionRestoreAbilityTest extends TestCase self::assertSame(813, $result['revision_id']); self::assertSame('restored', $result['changed'][0]['action']); self::assertStringContainsString('2026-09-20 10:00:00', $result['summary']); + self::assertStringContainsString('Status unverändert: publish', $result['summary']); + self::assertSame([], $result['warnings']); + } + + public function testRestoreFailureIsWriteFailed(): void + { + Functions\when('wp_save_post_revision')->justReturn(813); + Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]); + Functions\when('wp_restore_post_revision')->justReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->never(); + + $result = $this->ability($log)->execute([ + 'revision_id' => 810, + 'confirm' => true, + 'rev' => $this->currentRev(), + ]); + + self::assertSame('lit_write_failed', $result->get_error_code()); + } + + public function testFailuresAfterTheWriteOnlyAddWarnings(): void + { + Functions\when('wp_save_post_revision')->justReturn(813); + Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]); + Functions\when('wp_restore_post_revision')->justReturn(5); + Functions\when('get_preview_post_link')->justReturn(''); + Functions\when('get_edit_post_link')->justReturn(''); + Actions\expectDone('lit_mcp_after_save')->once()->whenHappen(static function (): void { + throw new RuntimeException('listener kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andThrow(new RuntimeException('db weg')); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = $this->ability($log)->execute([ + 'revision_id' => 810, + 'confirm' => true, + 'rev' => $this->currentRev(), + ]); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertTrue($result['ok']); + self::assertNull($result['log_id']); + self::assertCount(2, $result['warnings']); + self::assertStringContainsString('listener kaputt', (string) file_get_contents($file)); + unlink($file); } } diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php index ca8632f..8aeb846 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Cursor; +use LIT\MCP\Integrations\WordPress\Content\PostPresenter; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; @@ -14,28 +15,64 @@ use WP_Post; final class RevisionsListAbilityTest extends TestCase { - public function testListsRevisionsNewestFirstWithCursor(): void + protected function setUp(): void { + parent::setUp(); Functions\when('get_post')->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\when('get_post_meta')->justReturn(''); + } + + private function ability(): RevisionsListAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new RevisionsListAbility($schema); + } + + public function testListsRevisionsNewestFirstWithCursor(): void + { + Functions\when('wp_is_post_autosave')->alias(static fn ($post): bool => $post->ID === 811); 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]); + $result = $this->ability()->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::assertFalse($result['items'][0]['divi']); + self::assertSame([], $result['hints']); self::assertSame(2, Cursor::decode($result['next_cursor'])); } + + public function testMarksDiviRevisionsAndAddsTheHintOnce(): void + { + Functions\when('wp_is_post_autosave')->justReturn(false); + Functions\when('wp_get_post_revisions')->justReturn([ + 812 => new WP_Post(['ID' => 812, 'post_content' => '']), + 811 => new WP_Post(['ID' => 811, 'post_content' => 'Normal
']), + ]); + + $result = $this->ability()->execute(['post_id' => 5]); + + self::assertTrue($result['items'][0]['divi']); + self::assertFalse($result['items'][1]['divi']); + self::assertSame([PostPresenter::DIVI_HINT], $result['hints']); + } + + public function testInvalidCursorIsRejected(): void + { + $result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } }