# Etappe 1a – WordPress-Basis: Inhalte, Menüs, Einstellungen – Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Claude kann über den OAuth-geschützten MCP-Server Seiten, Beiträge und Pods-Einträge generisch finden, lesen, anlegen und bearbeiten, ihren Status explizit steuern, Entwurfskopien anlegen und zurücktauschen, Revisionen nutzen, Menüs aufbauen und die freigegebenen WordPress-Einstellungen setzen – jede Änderung mit einheitlichem `ToolResult` und Protokolleintrag. **Architecture:** Neue Integration `WordPressIntegration` (immer verfügbar) liefert die Abilities; eine `ContentSchema` führt Feldquellen zusammen (Pods → registrierte Meta → Meta-Stichprobe), `FieldValidator`/`TermAssigner`/`PostWriter` validieren alles vor dem ersten Schreibzugriff (alles oder nichts). Querschnitt: `Errors` (JSON-Fehler mit `hint`), `ToolResult` (Spec §8.1), `Cursor`, `Rev`, `ChangeLog` (Tabelle `{prefix}lit_mcp_log`, täglicher Cron), `RequestContext` (Flag-Reset nach dem Request, Client-Name für das Protokoll). Etappe 1b (separater Plan) ergänzt Medien, Einstellungsseite, Expositions-Policy, Hinweise und Aufräumen. **Tech Stack:** PHP ≥ 8.1, WordPress 7.0 Abilities API, `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, Pods 3.3.9 (optional), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security), DDEV. **Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §4.3–4.5, §6.1, §6.2, §6.4, §6.5, §8.1–8.3, §9, §10, §11.3, §12 Etappe 1) **Folgeplan:** `docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md` (Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Aufräumen, Hinweise). 1b setzt diesen Plan voraus. ## Global Constraints - Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Das installierte, inaktive Pods darf in DDEV für Integrationstests aktiviert werden (danach wieder deaktivieren); keine neuen Plugins installieren. - PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header `Tested up to: 7.0`. - Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` müssen nach jedem Task sauber sein. - Unit-Tests: PHPUnit 9.6 + Brain Monkey + Mockery über `LIT\MCP\Tests\Unit\TestCase`; Stubs für WP-Klassen in `tests/Stubs/` (in `tests/bootstrap.php` eingebunden). Funktionen, die `TestCase::setUp()` per `Functions\stubs()` vorbelegt (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`), nie mit `Functions\expect()` prüfen – Brain Monkey verbietet Stub und Erwartung auf derselben Funktion. - Integrationstests laufen im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`; WP-CLI über `timeout 120 ddev wp …`. - Ability-Namen `lit/-` → Tool-Namen `lit--`. Annotations exakt: Lese-Tools `readonly: true`; `destructive: true` nur für Löschen, Ersetzen, Austausch (Papierkorb, Kopie-Austausch, Revision wiederherstellen, Menübaum ersetzen); `idempotent` wie je Task angegeben. - Input- und Output-Schemas als JSON Schema 2020-12, nur mit Schlüsselwörtern, die `rest_validate_value_from_schema()` prüft (`type` inkl. Typ-Listen, `properties`, `required`, `items`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `minItems`, `maxItems`, `additionalProperties`). WordPress prüft Eingaben vor `execute()` und Ausgaben danach – eine Ausgabe, die nicht zum `outputSchema` passt, wird zum Fehler. - Fachliche Fehler ausschließlich über `LIT\MCP\Abilities\Errors` (Code `lit_`, Nachricht = JSON `{code, message, hint, …}`), nie Stacktraces. - Schreib-Tools ändern nie den Veröffentlichungsstatus; neue Einträge immer als Entwurf; Beiträge nur in den Papierkorb, nie endgültig löschen (einzige Ausnahme: Menüeinträge `nav_menu_item`, die WordPress selbst ohne Papierkorb löscht – Task 14). - Tools mit `destructive: true` verlangen `confirm: true`, sonst `lit_confirm_required` ohne jede Änderung. - Jede erfolgreiche Änderung schreibt einen Protokolleintrag (`ChangeLog::record`) und liefert `ToolResult` nach Spec §8.1; `revision_id` ist der Wiederherstellungspunkt **vor** der Änderung (oder `null`), `rev` der Hash des `post_content` danach (oder `null` bei Nicht-Beiträgen). - Listen mit Cursor-Paginierung: `limit` 1–100 (Standard 20), `cursor` rein, `next_cursor` (oder `null`) raus. - Keine Divi-Tools in dieser Etappe; Divi-Inhalte werden erkannt (`_et_pb_use_builder = on` oder `']); $full = (new PostPresenter($schema))->full($post); self::assertTrue($full['divi']); self::assertNull($full['content']); self::assertNull($full['featured_image']); self::assertStringContainsString('lit-divi-outline', $full['hints'][0]); self::assertInstanceOf(stdClass::class, $full['terms']); self::assertInstanceOf(stdClass::class, $full['fields']); } public function testDiviIsAlsoDetectedFromBlockMarkup(): void { Functions\when('get_post_meta')->justReturn(''); self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x y']))); self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '']))); } } ``` `tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php`: ```php allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]); return new ContentQueryAbility(Mockery::mock(ContentSchema::class), $presenter); } public function testBuildsTheQueryAndPaginates(): void { Functions\when('current_user_can')->justReturn(true); Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool { return $args['post_type'] === 'lit_person' && $args['post_status'] === ['draft'] && $args['s'] === 'Anna' && $args['posts_per_page'] === 3 && $args['offset'] === 4 && $args['orderby'] === 'title' && $args['order'] === 'ASC' && $args['tax_query'] === [ 'relation' => 'OR', ['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]], ['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']], ] && $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']] && $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]]; }))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]); $result = $this->ability()->execute([ 'post_type' => 'lit_person', 'status' => ['draft'], 'search' => 'Anna', 'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']], 'field' => ['key' => 'level', 'value' => 'senior'], 'date_after' => '2026-01-01', 'orderby' => 'title', 'order' => 'asc', 'limit' => 2, 'cursor' => Cursor::encode(4), ]); self::assertSame([['id' => 1], ['id' => 2]], $result['items']); self::assertSame(6, Cursor::decode($result['next_cursor'])); } public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void { Functions\when('current_user_can')->justReturn(true); Functions\expect('get_posts')->once()->with(Mockery::on( static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']] && $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future'] ))->andReturn([new WP_Post(['ID' => 1])]); $result = $this->ability()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]); self::assertNull($result['next_cursor']); } public function testPostsTheUserCannotEditAreDropped(): void { Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]); $result = $this->ability()->execute(['post_type' => 'page']); self::assertSame([['id' => 1]], $result['items']); } public function testInvalidCursorIsRejected(): void { $result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testPermissionNeedsAManagedTypeAndEditRights(): void { $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(false); $ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class)); self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code()); } } ``` `tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php`: ```php 5, 'post_type' => 'page']); Functions\when('get_post')->justReturn($post); Functions\when('current_user_can')->justReturn(true); $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); $presenter = Mockery::mock(PostPresenter::class); $presenter->expects('full')->with($post)->andReturn(['id' => 5]); $ability = new ContentGetAbility($schema, $presenter); self::assertTrue($ability->permission(['post_id' => 5])); self::assertSame(['id' => 5], $ability->execute(['post_id' => 5])); } public function testMissingPostIsNotFound(): void { Functions\when('get_post')->justReturn(null); $schema = Mockery::mock(ContentSchema::class); $ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class)); self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code()); } public function testOutputSchemaAllowsNullContentForDivi(): void { $ability = new ContentGetAbility(Mockery::mock(ContentSchema::class), Mockery::mock(PostPresenter::class)); self::assertSame(['string', 'null'], $ability->outputSchema()['properties']['content']['type']); self::assertTrue($ability->isDirect()); } } ``` In `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` die Konstante ersetzen: ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'PostPresenterTest|ContentQueryAbilityTest|ContentGetAbilityTest|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Content/PostPresenter.php`: ```php */ public function summary(WP_Post $post): array { return [ 'id' => (int) $post->ID, 'type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'title' => (string) $post->post_title, 'slug' => (string) $post->post_name, 'parent' => (int) $post->post_parent, 'date' => (string) $post->post_date, 'modified' => (string) $post->post_modified, 'url' => (string) get_permalink($post), 'edit_url' => (string) get_edit_post_link($post->ID, 'raw'), 'divi' => self::usesDivi($post), ]; } /** @return array */ public function full(WP_Post $post): array { $divi = self::usesDivi($post); return array_merge($this->summary($post), [ 'excerpt' => (string) $post->post_excerpt, 'content' => $divi ? null : (string) $post->post_content, 'menu_order' => (int) $post->menu_order, 'author' => [ 'id' => (int) $post->post_author, 'name' => (string) get_the_author_meta('display_name', (int) $post->post_author), ], 'template' => (string) get_page_template_slug($post), 'preview_url' => (string) get_preview_post_link($post), 'terms' => JsonMap::of($this->terms($post)), 'fields' => JsonMap::of($this->fields($post)), 'featured_image' => $this->featuredImage($post), 'rev' => Rev::of((string) $post->post_content), 'hints' => $divi ? [self::DIVI_HINT] : [], ]); } public static function usesDivi(WP_Post $post): bool { return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on' || str_contains((string) $post->post_content, '', 'post_excerpt' => 'Kurz', 'post_parent' => 2, 'menu_order' => 3, ]); } public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('maybe_unserialize')->returnArg(); Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77); Functions\when('get_post_meta')->justReturn([ '_edit_lock' => ['123:1'], '_et_pb_use_builder' => ['on'], 'role' => ['Leitung'], ]); Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_builder', 'on'); Functions\expect('add_post_meta')->once()->with(77, 'role', 'Leitung'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4, 9]); Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false); Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5); self::assertSame(77, (new PostCloner())->duplicate($this->original())); } public function testDuplicateFailureThrows(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt')); $this->expectException(RuntimeException::class); (new PostCloner())->duplicate($this->original()); } public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void { $copy = new WP_Post([ 'ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_content' => 'neu', 'post_excerpt' => 'Neu kurz', ]); Functions\when('maybe_unserialize')->returnArg(); Functions\expect('wp_update_post')->once()->with([ 'ID' => 5, 'post_title' => 'Team', 'post_content' => 'neu', 'post_excerpt' => 'Neu kurz', ], true)->andReturn(5); Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 ? ['_edit_lock' => ['1'], 'role' => ['Alt'], 'city' => ['Trier']] : ['_lit_mcp_copy_of' => ['5'], 'role' => ['Neu']]); Functions\expect('delete_post_meta')->once()->with(5, 'role'); Functions\expect('delete_post_meta')->once()->with(5, 'city'); Functions\expect('add_post_meta')->once()->with(5, 'role', 'Neu'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([12]); Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false); (new PostCloner())->replace($this->original(), $copy); } private function draftMatcher(): \Mockery\Matcher\Closure { return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' && $data['post_type'] === 'page' && $data['post_title'] === 'Team (Kopie)' && $data['post_content'] === '' && $data['post_parent'] === 2 && $data['menu_order'] === 3 && $data['post_author'] === 4); } } ``` `tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php`: ```php 5, 'post_type' => 'page', 'post_title' => 'Team']); Functions\when('get_post')->alias(static fn (int $id) => $id === 5 ? $original : new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)'])); Functions\when('current_user_can')->justReturn(true); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=77&preview=true'); Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=77&action=edit'); $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('duplicate')->with($original)->andReturn(77); Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8); $result = (new PostDuplicateAbility($schema, $cloner, $log))->execute(['post_id' => 5]); self::assertSame([['id' => '77', 'type' => 'page', 'action' => 'created', 'label' => 'Team (Kopie)']], $result['changed']); self::assertSame(5, $result['original_id']); self::assertSame(8, $result['log_id']); } } ``` `tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php`: ```php original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']); $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); Functions\when('current_user_can')->justReturn(true); Functions\when('get_post_meta')->justReturn('5'); 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'); } private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility { $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash); } public function testRequiresConfirmation(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['original_id' => 5, 'copy_id' => 77]); self::assertSame('lit_confirm_required', $result->get_error_code()); } public function testCopyMustStemFromTheOriginal(): void { Functions\when('get_post_meta')->justReturn('12'); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class)) ->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testDisabledTrashIsRefusedBeforeAnyChange(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false) ->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); self::assertSame('lit_unsupported', $result->get_error_code()); } public function testRevisionThenReplaceThenTrashTheCopy(): void { $order = []; Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { $order[] = 'revision'; return 900; }); Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void { $order[] = 'replace'; }); Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post { $order[] = 'trash'; return new WP_Post(['ID' => 77]); }); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11); $result = $this->ability($cloner, $log)->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); self::assertSame(['revision', 'replace', 'trash'], $order); self::assertSame(900, $result['revision_id']); self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action')); } } ``` In `WordPressIntegrationTest` die Konstante ersetzen: ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', 'lit/content-create', 'lit/content-update', 'lit/post-publish', 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'PostCloner|PostDuplicate|PostReplaceWithCopy|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Status/PostCloner.php`: ```php (string) $original->post_type, 'post_status' => 'draft', 'post_title' => sprintf('%s (Kopie)', $original->post_title), 'post_content' => (string) $original->post_content, 'post_excerpt' => (string) $original->post_excerpt, 'post_parent' => (int) $original->post_parent, 'menu_order' => (int) $original->menu_order, 'comment_status' => (string) $original->comment_status, 'ping_status' => (string) $original->ping_status, 'post_password' => (string) $original->post_password, 'post_author' => get_current_user_id(), ]), true); if ($copyId instanceof WP_Error || (int) $copyId === 0) { $reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown'; throw new RuntimeException('Copy could not be created: ' . $reason); } $copyId = (int) $copyId; $this->copyMeta((int) $original->ID, $copyId); $this->copyTerms($original, $copyId); update_post_meta($copyId, self::COPY_OF_META, (int) $original->ID); return $copyId; } /** @throws RuntimeException */ public function replace(WP_Post $original, WP_Post $copy): void { $result = wp_update_post(wp_slash([ 'ID' => (int) $original->ID, 'post_title' => (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title), 'post_content' => (string) $copy->post_content, 'post_excerpt' => (string) $copy->post_excerpt, ]), true); if ($result instanceof WP_Error) { throw new RuntimeException('Original could not be updated: ' . $result->get_error_message()); } foreach (array_keys((array) get_post_meta((int) $original->ID)) as $key) { if (! in_array((string) $key, self::SKIP_META, true)) { delete_post_meta((int) $original->ID, (string) $key); } } $this->copyMeta((int) $copy->ID, (int) $original->ID); $this->copyTerms($copy, (int) $original->ID); } private function copyMeta(int $from, int $to): void { foreach ((array) get_post_meta($from) as $key => $values) { if (in_array((string) $key, self::SKIP_META, true)) { continue; } foreach ((array) $values as $value) { add_post_meta($to, (string) $key, wp_slash(maybe_unserialize($value))); } } } private function copyTerms(WP_Post $from, int $to): void { foreach (get_object_taxonomies((string) $from->post_type) as $taxonomy) { $ids = wp_get_object_terms((int) $from->ID, $taxonomy, ['fields' => 'ids']); if (is_array($ids)) { wp_set_object_terms($to, array_map('intval', $ids), $taxonomy, false); } } } } ``` `src/Integrations/WordPress/Status/PostDuplicateAbility.php`: ```php 'object', 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], 'required' => ['post_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(['original_id' => ['type' => 'integer']], ['original_id']); } public function annotations(): array { return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; } 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 { $original = $this->load($input); if ($original instanceof WP_Error) { return $original; } $copyId = $this->cloner->duplicate($original); do_action('lit_mcp_after_save', $copyId, $this->name(), $input); $copy = get_post($copyId); $copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : ''; $postType = (string) $original->post_type; $summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID); $logId = $this->log->record($this->name(), $postType, (string) $copyId, $summary); return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId)) ->changed((string) $copyId, $postType, 'created', $copyTitle) ->logId($logId) ->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw')) ->with('original_id', (int) $original->ID) ->toArray(); } /** @param array $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; } $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts'); return $type instanceof WP_Error ? $type : $post; } } ``` `src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php`: ```php trashEnabled = $trashEnabled ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; } public function name(): string { return 'lit/post-replace-with-copy'; } public function label(): string { return 'Kopie ins Original übernehmen'; } public function description(): string { return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie ' . 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine ' . 'Revision des Originals, die Kopie kommt danach in den Papierkorb. Erst nach Rückfrage mit ' . 'confirm: true aufrufen.'; } public function inputSchema(): array { return [ 'type' => 'object', 'properties' => [ 'original_id' => ['type' => 'integer', 'minimum' => 1], 'copy_id' => ['type' => 'integer', 'minimum' => 1], 'confirm' => ['type' => 'boolean'], ], 'required' => ['original_id', 'copy_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(); } public function annotations(): array { return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; } 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; } [$original, $copy] = $pair; $originalId = (int) $original->ID; $copyId = (int) $copy->ID; if (($input['confirm'] ?? false) !== true) { return Errors::confirmRequired(sprintf( 'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?', $original->post_title, $originalId, $copyId )); } $copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true); if ($copy->post_type !== $original->post_type || $copyOf !== $originalId) { return Errors::invalidInput( 'Die Kopie gehört nicht zu diesem Original.', ['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)], 'Kopie mit lit-post-duplicate aus diesem Original erstellen.' ); } if (! ($this->trashEnabled)()) { return Errors::unsupported( 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. ' . 'Nichts wurde geändert.', 'Den Austausch in WordPress manuell durchführen.' ); } $revisionId = RevisionPoint::capture($originalId); $this->cloner->replace($original, $copy); wp_trash_post($copyId); do_action('lit_mcp_after_save', $originalId, $this->name(), $input); $title = (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title); $postType = (string) $original->post_type; $summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId); $logId = $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId); $statusNote = sprintf('; Status bleibt %s, Kopie liegt im Papierkorb.', $original->post_status); return ToolResult::ok($summary . $statusNote) ->changed((string) $originalId, $postType, 'replaced', $title) ->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title) ->revisionId($revisionId) ->logId($logId) ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) ->toArray(); } /** * @param array $input * @return array{0: WP_Post, 1: WP_Post}|WP_Error */ private function load(array $input): array|WP_Error { $original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id'); if ($original instanceof WP_Error) { return $original; } $copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id'); if ($copy instanceof WP_Error) { return $copy; } if (! current_user_can('delete_post', (int) $copy->ID)) { return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.'); } return [$original, $copy]; } } ``` `src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen ```php use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility; use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility; ``` und in `abilities()` vor dem `return` `$cloner = new PostCloner();` ergänzen sowie nach `new PostTrashAbility(...)` einfügen: ```php new PostDuplicateAbility($this->schema, $cloner, $this->log), new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: PASS (lange Zeilen für phpcs umbrechen). - [ ] **Step 5: Commit** ```bash git add src/Integrations/WordPress tests/Unit/Integrations/WordPress git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: draft copies and replace-with-copy Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 12: Revisionen – `lit/revisions-list`, `lit/revision-restore` **Files:** - Create: `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`, `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php` - Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` - Test: `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`, `tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php` **Interfaces:** - Consumes: `PostGuard`, `RevisionPoint`, `Cursor`, `Rev`, `ChangeLog`, `ToolResult`, `Errors`. - Produces: - `RevisionsListAbility`: `__construct(ContentSchema $schema)`; `lit/revisions-list`, Input `{post_id, limit?, cursor?}`, Output `{post_id, items: list<{id, date, author{id,name}, title, autosave, excerpt}>, next_cursor}`; indirekt; readonly. - `RevisionRestoreAbility`: `__construct(ContentSchema $schema, ChangeLog $log)`; `lit/revision-restore`, Input `{revision_id, confirm}`; indirekt; `destructive: true`, idempotent; sichert den aktuellen Stand vorher als Revision (`revision_id` im Ergebnis = dieser Wiederherstellungspunkt); Status unverändert. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`: ```php 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'])); } } ``` `tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php`: ```php 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(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish', 'post_content' => 'alt']), 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); } 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 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]); 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']); } } ``` In `WordPressIntegrationTest` die Konstante um `'lit/revisions-list', 'lit/revision-restore'` (nach `'lit/post-replace-with-copy'`) ergänzen: ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', 'lit/content-create', 'lit/content-update', 'lit/post-publish', 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', 'lit/revisions-list', 'lit/revision-restore', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'Revision|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`: ```php '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), ]; } } ``` `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php`: ```php 'object', 'properties' => [ 'revision_id' => ['type' => 'integer', 'minimum' => 1], 'confirm' => ['type' => 'boolean'], ], 'required' => ['revision_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 { $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 )); } $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::of((string) $updated->post_content)) ->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.'); } $post = PostGuard::editable((int) $revision->post_parent, $this->schema); return $post instanceof WP_Error ? $post : [$revision, $post]; } } ``` `src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen ```php use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; ``` und in `abilities()` nach `new PostReplaceWithCopyAbility(...)` einfügen: ```php new RevisionsListAbility($this->schema), new RevisionRestoreAbility($this->schema, $this->log), ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/Integrations/WordPress tests/Unit/Integrations/WordPress git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: list and restore revisions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 13: Menüs lesen, anlegen, Positionen – `lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-assign-location` Recht für alle Menü-Tools: `edit_theme_options` (Spec §4.5). **Files:** - Create: `src/Integrations/WordPress/Menus/MenuTree.php`, `src/Integrations/WordPress/Menus/MenuLocations.php`, `src/Integrations/WordPress/Menus/MenuListAbility.php`, `src/Integrations/WordPress/Menus/MenuGetAbility.php`, `src/Integrations/WordPress/Menus/MenuCreateAbility.php`, `src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php` - Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` - Test: `tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php` **Interfaces:** - Consumes: `ChangeLog`, `ToolResult`, `Errors`. - Produces: - `MenuTree` (final, statisch): `build(list $items): list` mit `Node = {id: int, title: string, type: 'post'|'taxonomy'|'custom', object: string, object_id: int, url: string, children: list}` (Einträge mit fehlendem Elternteil landen auf oberster Ebene); `node(object $item): array` (ohne `children`); `nodeSchema(): array`. - `MenuLocations` (final, statisch): `registered(): array`, `assigned(): array`, `of(int $menuId): list`, `assign(string $location, int $menuId): void` (`0` = Zuordnung entfernen). - `MenuListAbility()` – `lit/menu-list`, Output `{menus: list<{id, name, slug, count, locations}>, locations: list<{location, description, menu_id|null}>}`; indirekt; readonly. - `MenuGetAbility()` – `lit/menu-get`, Input `{menu_id}`, Output `{id, name, locations, items: list}`; indirekt; readonly. - `MenuCreateAbility(ChangeLog $log)` – `lit/menu-create`, Input `{name, location?}`; Ergebnis-Extra `menu_id`; indirekt; nicht idempotent. - `MenuAssignLocationAbility(ChangeLog $log)` – `lit/menu-assign-location`, Input `{location, menu_id}` (`0` entfernt); Ergebnis-Extra `previous_menu_id` (int|null); indirekt; idempotent. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`: ```php $id, 'menu_item_parent' => (string) $parent, 'title' => $title, 'post_title' => $type === 'custom' ? $title : '', 'type' => $type, 'object' => $type === 'custom' ? 'custom' : 'page', 'object_id' => (string) ($type === 'custom' ? $id : $id + 100), 'url' => $url, 'description' => '', 'attr_title' => '', 'target' => '', 'classes' => [''], 'xfn' => '', ]; } public function testBuildsANestedTreeInMenuOrder(): void { $tree = MenuTree::build([ self::item(10, 0, 'Start'), self::item(11, 0, 'Über uns'), self::item(12, 11, 'Team'), self::item(13, 0, 'Kontakt', 'custom', '/kontakt'), ]); self::assertSame(['Start', 'Über uns', 'Kontakt'], array_column($tree, 'title')); self::assertSame('Team', $tree[1]['children'][0]['title']); self::assertSame([], $tree[1]['children'][0]['children']); self::assertSame('custom', $tree[2]['type']); self::assertSame('post', $tree[0]['type']); self::assertSame(110, $tree[0]['object_id']); } public function testOrphansAndSelfParentsLandOnTheTopLevel(): void { $tree = MenuTree::build([self::item(20, 99, 'Waise'), self::item(21, 21, 'Zirkel')]); self::assertSame(['Waise', 'Zirkel'], array_column($tree, 'title')); } } ``` `tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`: ```php justReturn([ (object) ['term_id' => 3, 'name' => 'Hauptmenü', 'slug' => 'hauptmenue', 'count' => 4], ]); Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü', 'footer-menu' => 'Footer']); Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]); $result = (new MenuListAbility())->execute([]); self::assertSame(['primary-menu'], $result['menus'][0]['locations']); self::assertSame( [ ['location' => 'primary-menu', 'description' => 'Hauptmenü', 'menu_id' => 3], ['location' => 'footer-menu', 'description' => 'Footer', 'menu_id' => null], ], $result['locations'] ); } public function testGetReturnsTheTreeOrNotFound(): void { Functions\when('wp_get_nav_menu_object')->alias( static fn (int $id) => $id === 3 ? (object) ['term_id' => 3, 'name' => 'Hauptmenü'] : false ); Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Start')]); Functions\when('get_nav_menu_locations')->justReturn([]); $ability = new MenuGetAbility(); $result = $ability->execute(['menu_id' => 3]); self::assertSame('Hauptmenü', $result['name']); self::assertSame('Start', $result['items'][0]['title']); self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code()); } public function testReadToolsNeedThemeOptionRights(): void { Functions\when('current_user_can')->justReturn(false); self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code()); self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code()); } } ``` `tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`: ```php justReturn(['primary-menu' => 'Hauptmenü']); Functions\expect('wp_create_nav_menu')->never(); $result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Neu', 'location' => 'nope']); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertStringContainsString('primary-menu', $result->get_error_message()); } public function testExistingNameIsAValidationError(): void { Functions\when('wp_create_nav_menu')->justReturn(new WP_Error('menu_exists', 'Der Menüname existiert bereits.')); $result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Hauptmenü']); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testCreatesAndAssignsTheLocation(): void { Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']); Functions\expect('wp_create_nav_menu')->once()->with('Neu')->andReturn(8); Functions\when('get_nav_menu_locations')->justReturn(['footer-menu' => 2]); Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['footer-menu' => 2, 'primary-menu' => 8]); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/menu-create', 'nav_menu', '8', 'Menü "Neu" angelegt (Position primary-menu)')->andReturn(5); $result = (new MenuCreateAbility($log))->execute(['name' => 'Neu', 'location' => 'primary-menu']); self::assertSame(8, $result['menu_id']); self::assertSame('created', $result['changed'][0]['action']); } } ``` `tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php`: ```php justReturn(['primary-menu' => 'Hauptmenü']); Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]); Functions\when('wp_get_nav_menu_object')->alias(static fn (int $id) => $id === 8 ? (object) ['term_id' => 8, 'name' => 'Neu'] : false); } public function testAssignsAndReportsThePreviousMenu(): void { Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['primary-menu' => 8]); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->andReturn(6); $result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 8]); self::assertSame(3, $result['previous_menu_id']); self::assertSame('assigned', $result['changed'][0]['action']); } public function testZeroRemovesTheAssignment(): void { Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', []); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->andReturn(7); $result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 0]); self::assertSame('unassigned', $result['changed'][0]['action']); } public function testUnknownMenuIsNotFound(): void { Functions\expect('set_theme_mod')->never(); $result = (new MenuAssignLocationAbility(Mockery::mock(ChangeLog::class))) ->execute(['location' => 'primary-menu', 'menu_id' => 99]); self::assertSame('lit_not_found', $result->get_error_code()); } } ``` In `WordPressIntegrationTest` die Konstante ersetzen (Reihenfolge wie in `abilities()`): ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', 'lit/content-create', 'lit/content-update', 'lit/post-publish', 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', 'lit/revisions-list', 'lit/revision-restore', 'lit/menu-list', 'lit/menu-get', 'lit/menu-create', 'lit/menu-assign-location', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'Menu|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Menus/MenuTree.php`: ```php $items Sorted by menu_order (wp_get_nav_menu_items default). * @return list> */ public static function build(array $items): array { $nodes = []; foreach ($items as $item) { $nodes[(int) $item->ID] = self::node($item); } $children = []; foreach ($items as $item) { $id = (int) $item->ID; $parent = (int) $item->menu_item_parent; $children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id; } return self::attach(0, $nodes, $children); } /** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */ public static function node(object $item): array { return [ 'id' => (int) $item->ID, 'title' => (string) $item->title, 'type' => match ((string) $item->type) { 'post_type' => 'post', 'taxonomy' => 'taxonomy', default => 'custom', }, 'object' => (string) $item->object, 'object_id' => (int) $item->object_id, 'url' => (string) $item->url, ]; } /** @return array JSON schema of one tree node (children not validated deeper). */ public static function nodeSchema(): array { return [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'type' => ['type' => 'string'], 'object' => ['type' => 'string'], 'object_id' => ['type' => 'integer'], 'url' => ['type' => 'string'], 'children' => ['type' => 'array'], ], 'required' => ['id', 'title', 'type', 'children'], ]; } /** * @param array> $nodes * @param array> $children * @return list> */ private static function attach(int $parent, array $nodes, array $children): array { $result = []; foreach ($children[$parent] ?? [] as $id) { $result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]); } return $result; } } ``` `src/Integrations/WordPress/Menus/MenuLocations.php`: ```php Location slug => description. */ public static function registered(): array { return array_map('strval', (array) get_registered_nav_menus()); } /** @return array Location slug => menu ID. */ public static function assigned(): array { $locations = get_nav_menu_locations(); return is_array($locations) ? array_map('intval', $locations) : []; } /** @return list */ public static function of(int $menuId): array { return array_map('strval', array_keys(array_filter( self::assigned(), static fn (int $id): bool => $id === $menuId ))); } public static function assign(string $location, int $menuId): void { $locations = self::assigned(); if ($menuId > 0) { $locations[$location] = $menuId; } else { unset($locations[$location]); } set_theme_mod('nav_menu_locations', $locations); } } ``` `src/Integrations/WordPress/Menus/MenuListAbility.php`: ```php 'object', 'properties' => [], 'additionalProperties' => false]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'menus' => ['type' => 'array', 'items' => ['type' => 'object']], 'locations' => ['type' => 'array', 'items' => ['type' => 'object']], ], 'required' => ['menus', 'locations'], ]; } 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 { return current_user_can('edit_theme_options') ? true : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); } public function execute(array $input): mixed { $menus = []; foreach ((array) wp_get_nav_menus() as $menu) { $menus[] = [ 'id' => (int) $menu->term_id, 'name' => (string) $menu->name, 'slug' => (string) $menu->slug, 'count' => (int) $menu->count, 'locations' => MenuLocations::of((int) $menu->term_id), ]; } $assigned = MenuLocations::assigned(); $locations = []; foreach (MenuLocations::registered() as $location => $description) { $menuId = $assigned[$location] ?? 0; $locations[] = [ 'location' => (string) $location, 'description' => $description, 'menu_id' => $menuId > 0 ? $menuId : null, ]; } return ['menus' => $menus, 'locations' => $locations]; } } ``` `src/Integrations/WordPress/Menus/MenuGetAbility.php`: ```php 'object', 'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]], 'required' => ['menu_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'locations' => ['type' => 'array', 'items' => ['type' => 'string']], 'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()], ], 'required' => ['id', 'name', 'locations', 'items'], ]; } 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 { return current_user_can('edit_theme_options') ? true : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); } public function execute(array $input): mixed { $menuId = (int) ($input['menu_id'] ?? 0); $menu = wp_get_nav_menu_object($menuId); if (! is_object($menu)) { return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); } $items = wp_get_nav_menu_items($menuId); return [ 'id' => (int) $menu->term_id, 'name' => (string) $menu->name, 'locations' => MenuLocations::of((int) $menu->term_id), 'items' => MenuTree::build(is_array($items) ? $items : []), ]; } } ``` `src/Integrations/WordPress/Menus/MenuCreateAbility.php`: ```php 'object', 'properties' => [ 'name' => ['type' => 'string', 'minLength' => 1], 'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'], ], 'required' => ['name'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']); } public function annotations(): array { return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; } public function isDirect(): bool { return false; } public function permission(array $input): bool|WP_Error { return current_user_can('edit_theme_options') ? true : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); } public function execute(array $input): mixed { $name = trim((string) ($input['name'] ?? '')); $location = isset($input['location']) ? (string) $input['location'] : ''; if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) { return Errors::invalidInput( 'Unbekannte Menü-Position.', ['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))] ); } $menuId = wp_create_nav_menu($name); if ($menuId instanceof WP_Error) { return Errors::invalidInput( 'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(), ['name' => 'bereits vergeben oder ungültig'], 'Vorhandene Menüs über lit-menu-list abrufen.' ); } $menuId = (int) $menuId; if ($location !== '') { MenuLocations::assign($location, $menuId); } $summary = sprintf('Menü "%s" angelegt', $name) . ($location !== '' ? sprintf(' (Position %s)', $location) : ''); $logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary); return ToolResult::ok($summary . sprintf(', ID %d.', $menuId)) ->changed((string) $menuId, 'nav_menu', 'created', $name) ->logId($logId) ->with('menu_id', $menuId) ->toArray(); } } ``` `src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php`: ```php 'object', 'properties' => [ 'location' => ['type' => 'string', 'minLength' => 1], 'menu_id' => ['type' => 'integer', 'minimum' => 0], ], 'required' => ['location', 'menu_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']); } 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 { return current_user_can('edit_theme_options') ? true : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); } public function execute(array $input): mixed { $location = (string) ($input['location'] ?? ''); $menuId = (int) ($input['menu_id'] ?? 0); $registered = MenuLocations::registered(); if (! array_key_exists($location, $registered)) { return Errors::invalidInput( 'Unbekannte Menü-Position.', ['location' => 'verfügbar: ' . implode(', ', array_keys($registered))] ); } if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) { return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); } $previous = MenuLocations::assigned()[$location] ?? 0; MenuLocations::assign($location, $menuId); $action = $menuId > 0 ? 'assigned' : 'unassigned'; $summary = $menuId > 0 ? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location) : sprintf('Zuweisung der Position %s entfernt', $location); $logId = $this->log->record($this->name(), 'menu_location', $location, $summary); return ToolResult::ok($summary . '.') ->changed($location, 'menu_location', $action, $registered[$location]) ->logId($logId) ->with('previous_menu_id', $previous > 0 ? $previous : null) ->toArray(); } } ``` `src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen ```php use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; ``` und in `abilities()` nach `new RevisionRestoreAbility(...)` einfügen: ```php new MenuListAbility(), new MenuGetAbility(), new MenuCreateAbility($this->log), new MenuAssignLocationAbility($this->log), ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/Integrations/WordPress tests/Unit/Integrations/WordPress git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: menu list, get, create and location tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 14: Menüeinträge setzen – `MenuOperations`, `MenuWriter`, `lit/menu-set-items` Spec §6.4: Einträge deklarativ als Gesamtbaum **oder** als Einzeloperationen (hinzufügen/verschieben/entfernen). Beide Wege laufen durch denselben Schreibpfad: Operationen werden auf den aktuellen Baum angewendet und ergeben einen Zielbaum. Jede Entfernung braucht `confirm: true`; die Antwort enthält immer `previous_items` (Baum vorher), damit Claude einen Fehler zurückbauen kann. Menüeinträge (`nav_menu_item`) löscht WordPress selbst ohne Papierkorb (`wp_delete_post($id, true)`, wie der Menü-Editor) – das ist die einzige Ausnahme von „nie endgültig löschen“, sie betrifft nur Menüstruktur, keine Inhalte. **Files:** - Create: `src/Integrations/WordPress/Menus/MenuOperations.php`, `src/Integrations/WordPress/Menus/MenuWriter.php`, `src/Integrations/WordPress/Menus/MenuSetItemsAbility.php` - Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` - Test: `tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php` **Interfaces:** - Consumes: `MenuTree::build()` (Task 13), `ChangeLog`, `ToolResult`, `Errors`, Test-Helfer `MenuTreeTest::item()`. - Produces: - Zielbaum-Knoten (Eingabe): bestehender Eintrag `{id, title?, url? (nur custom), children?}`; neuer Eintrag `{type: 'post'|'taxonomy'|'custom', object_id? (post/taxonomy), url? (custom), title? (custom: Pflicht), children?}`. - `MenuOperations` (final): `apply(list $tree, list $operations): list|WP_Error` – Operationen `{op: 'add', item: {…}, parent_id?, position?}`, `{op: 'move', id, parent_id?, position?}`, `{op: 'remove', id}` (Kinder rücken an die Stelle des entfernten Eintrags); `position` = 0-basierter Index unter den Geschwistern, ohne = ans Ende. - `MenuWriter` (nicht final): `plan(list $tree, array $current): array{errors: array, removals: list, warnings: list}` (schreibt nichts); `apply(int $menuId, list $tree, array $current): array{created: int, updated: int, removed: int}` (wirft `RuntimeException`). - `MenuSetItemsAbility`: `__construct(MenuWriter $writer, MenuOperations $operations, ChangeLog $log)`; `lit/menu-set-items`, Input `{menu_id, items? | operations?, confirm?}`; Ergebnis-Extras `items` (Baum nachher), `previous_items` (Baum vorher); indirekt; `destructive: true`, nicht idempotent. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`: ```php > Start(1) > Team(2); Kontakt(3) */ private function tree(): array { return [ ['id' => 1, 'title' => 'Start', 'children' => [['id' => 2, 'title' => 'Team', 'children' => []]]], ['id' => 3, 'title' => 'Kontakt', 'children' => []], ]; } public function testAddInsertsANewNodeAtThePosition(): void { $result = (new MenuOperations())->apply($this->tree(), [ ['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog'], 'position' => 1], ]); self::assertSame(1, $result[0]['id']); self::assertSame(['type' => 'custom', 'url' => '/blog', 'title' => 'Blog', 'children' => []], $result[1]); self::assertSame(3, $result[2]['id']); } public function testMoveAndRemoveKeepChildren(): void { $result = (new MenuOperations())->apply($this->tree(), [ ['op' => 'move', 'id' => 3, 'parent_id' => 1, 'position' => 0], ['op' => 'remove', 'id' => 1], ]); self::assertSame([3, 2], array_column($result, 'id')); self::assertSame([], $result[0]['children']); } public function testInvalidOperationsAreReportedWithTheirIndex(): void { $operations = new MenuOperations(); $unknown = $operations->apply($this->tree(), [['op' => 'move', 'id' => 99]]); $cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]); $badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]); self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']); self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']); self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']); } } ``` `tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`: ```php */ private function current(): array { return [ 10 => MenuTreeTest::item(10, 0, 'Home', 'custom', '/'), 11 => MenuTreeTest::item(11, 0, 'Über uns'), ]; } public function testPlanFindsErrorsRemovalsAndWarnings(): void { Functions\when('wp_parse_url')->alias('parse_url'); Functions\when('get_post')->alias(static fn (int $id) => $id === 7 ? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu']) : null); $plan = (new MenuWriter())->plan([ ['id' => 11, 'children' => [['id' => 11]]], ['id' => 55], ['type' => 'post', 'object_id' => 7], ['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'], ['type' => 'custom', 'url' => '/kontakt'], ], $this->current()); self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']); self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']); self::assertArrayNotHasKey('items.2', $plan['errors']); self::assertStringContainsString('url', $plan['errors']['items.3']); self::assertStringContainsString('title', $plan['errors']['items.4']); self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']); self::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][0]); } public function testApplyWritesInTreeOrderKeepsExistingDataAndRemovesTheRest(): void { Functions\when('esc_url_raw')->returnArg(); Functions\expect('wp_update_nav_menu_item')->once()->with(3, 11, Mockery::on(static fn (array $args): bool => $args['menu-item-title'] === 'Wir' && $args['menu-item-type'] === 'post_type' && $args['menu-item-object-id'] === 111 && $args['menu-item-parent-id'] === 0 && $args['menu-item-position'] === 1 && $args['menu-item-status'] === 'publish'))->andReturn(11); Functions\expect('wp_update_nav_menu_item')->once()->with(3, 0, Mockery::on(static fn (array $args): bool => $args['menu-item-type'] === 'custom' && $args['menu-item-url'] === '/kontakt' && $args['menu-item-title'] === 'Kontakt' && $args['menu-item-parent-id'] === 11 && $args['menu-item-position'] === 2))->andReturn(12); Functions\expect('wp_delete_post')->once()->with(10, true); $stats = (new MenuWriter())->apply(3, [ ['id' => 11, 'title' => 'Wir', 'children' => [['type' => 'custom', 'url' => '/kontakt', 'title' => 'Kontakt']]], ], $this->current()); self::assertSame(['created' => 1, 'updated' => 1, 'removed' => 1], $stats); } } ``` `tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php`: ```php justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']); Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Home', 'custom', '/')]); } public function testItemsAndOperationsAreMutuallyExclusive(): void { $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class)); self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3])->get_error_code()); self::assertSame( 'lit_validation_failed', $ability->execute(['menu_id' => 3, 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]])->get_error_code() ); } public function testRemovalsNeedConfirmationAndNothingIsWritten(): void { Functions\expect('wp_update_nav_menu_item')->never(); Functions\expect('wp_delete_post')->never(); $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class)); $result = $ability->execute(['menu_id' => 3, 'items' => []]); self::assertSame('lit_confirm_required', $result->get_error_code()); self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']); } public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void { $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); $writer->expects('apply')->andReturn(['created' => 1, 'updated' => 1, 'removed' => 0]); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string'))->andReturn(21); $result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([ 'menu_id' => 3, 'operations' => [['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog']]], ]); self::assertSame('Home', $result['previous_items'][0]['title']); self::assertSame(21, $result['log_id']); self::assertStringContainsString('1 neu, 1 geändert, 0 entfernt', $result['summary']); } } ``` In `WordPressIntegrationTest` die Konstante um `'lit/menu-set-items'` nach `'lit/menu-create'` ergänzen: ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', 'lit/content-create', 'lit/content-update', 'lit/post-publish', 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', 'lit/revisions-list', 'lit/revision-restore', 'lit/menu-list', 'lit/menu-get', 'lit/menu-create', 'lit/menu-set-items', 'lit/menu-assign-location', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'MenuOperations|MenuWriter|MenuSetItems|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Menus/MenuOperations.php`: ```php > $tree Current tree (MenuTree::build()). * @param list $operations * @return list>|WP_Error */ public function apply(array $tree, array $operations): array|WP_Error { $nodes = []; $children = [self::ROOT => []]; $this->flatten($tree, self::ROOT, $nodes, $children); foreach ($operations as $index => $operation) { $error = $this->applyOne($operation, (int) $index, $nodes, $children); if ($error !== null) { return Errors::invalidInput( 'Menü-Operation ungültig; nichts wurde geändert.', ['operations.' . $index => $error] ); } } return $this->nest(self::ROOT, $nodes, $children); } /** * @param list> $tree * @param array> $nodes * @param array> $children */ private function flatten(array $tree, string $parent, array &$nodes, array &$children): void { foreach ($tree as $item) { $key = 'id:' . (int) $item['id']; $nodes[$key] = ['id' => (int) $item['id']]; $children[$parent][] = $key; $children[$key] = []; $this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children); } } /** * @param array> $nodes * @param array> $children */ private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string { if (! is_array($operation)) { return 'Objekt erwartet.'; } $parentId = (int) ($operation['parent_id'] ?? 0); $parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT; if (! isset($children[$parentKey])) { return 'parent_id ist kein Eintrag dieses Menüs.'; } $position = isset($operation['position']) ? (int) $operation['position'] : null; $key = 'id:' . (int) ($operation['id'] ?? 0); switch ($operation['op'] ?? '') { case 'add': if (! is_array($operation['item'] ?? null)) { return 'item fehlt.'; } $newKey = 'new:' . $index; $nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]); $children[$newKey] = []; $this->insert($children[$parentKey], $newKey, $position); return null; case 'move': if (! isset($nodes[$key])) { return 'id ist kein Eintrag dieses Menüs.'; } if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) { return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.'; } $this->detach($key, $children); $this->insert($children[$parentKey], $key, $position); return null; case 'remove': if (! isset($nodes[$key])) { return 'id ist kein Eintrag dieses Menüs.'; } [$owner, $at] = $this->detach($key, $children); array_splice($children[$owner], $at, 0, $children[$key]); unset($nodes[$key], $children[$key]); return null; default: return 'op muss add, move oder remove sein.'; } } /** @param list $list */ private function insert(array &$list, string $key, ?int $position): void { if ($position === null || $position >= count($list)) { $list[] = $key; return; } array_splice($list, max(0, $position), 0, [$key]); } /** * @param array> $children * @return array{0: string, 1: int} Former parent key and index. */ private function detach(string $key, array &$children): array { foreach ($children as $parent => $list) { $index = array_search($key, $list, true); if ($index !== false) { array_splice($children[$parent], (int) $index, 1); return [(string) $parent, (int) $index]; } } return [self::ROOT, count($children[self::ROOT])]; } /** @param array> $children */ private function isDescendant(string $candidate, string $ancestor, array $children): bool { foreach ($children[$ancestor] ?? [] as $child) { if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) { return true; } } return false; } /** * @param array> $nodes * @param array> $children * @return list> */ private function nest(string $parent, array $nodes, array $children): array { $result = []; foreach ($children[$parent] ?? [] as $key) { $result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]); } return $result; } } ``` `src/Integrations/WordPress/Menus/MenuWriter.php`: ```php $tree * @param array $current Current items by ID. * @return array{errors: array, removals: list, warnings: list} */ public function plan(array $tree, array $current): array { $errors = []; $warnings = []; $seen = []; $this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings); $removals = []; foreach ($current as $id => $item) { if (! isset($seen[$id])) { $removals[] = ['id' => (int) $id, 'title' => (string) $item->title]; } } return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings]; } /** * @param list> $tree Validated by plan(). * @param array $current * @return array{created: int, updated: int, removed: int} * @throws RuntimeException */ public function apply(int $menuId, array $tree, array $current): array { $stats = ['created' => 0, 'updated' => 0, 'removed' => 0]; $position = 0; $seen = []; $this->writeNodes($menuId, $tree, 0, $current, $position, $seen, $stats); foreach (array_keys($current) as $id) { if (! isset($seen[$id])) { // Menu items have no trash in WordPress; the core menu editor deletes them the same way. wp_delete_post((int) $id, true); $stats['removed']++; } } return $stats; } /** * @param array $current * @param array $seen * @param array $errors * @param list $warnings */ private function validateNodes( mixed $nodes, string $path, array $current, array &$seen, array &$errors, array &$warnings ): void { if (! is_array($nodes) || ! array_is_list($nodes)) { $errors[$path] = 'Liste erwartet.'; return; } foreach ($nodes as $index => $node) { $nodePath = $path . '.' . $index; if (! is_array($node)) { $errors[$nodePath] = 'Objekt erwartet.'; continue; } $error = array_key_exists('id', $node) ? $this->checkExisting($node, $current, $seen) : $this->checkNew($node, $warnings); if ($error !== null) { $errors[$nodePath] = $error; } if (array_key_exists('children', $node)) { $this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings); } } } /** * @param array $node * @param array $current * @param array $seen */ private function checkExisting(array $node, array $current, array &$seen): ?string { $id = $node['id']; if (! is_int($id) || ! isset($current[$id])) { return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).'; } if (isset($seen[$id])) { return sprintf('Eintrag %d kommt mehrfach vor.', $id); } $seen[$id] = true; if (array_key_exists('title', $node) && ! is_string($node['title'])) { return 'title muss Text sein.'; } if (array_key_exists('url', $node)) { if ((string) $current[$id]->type !== 'custom') { return 'url ist nur bei individuellen Links änderbar.'; } if (! $this->validUrl($node['url'])) { return 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'; } } return null; } /** * @param array $node * @param list $warnings */ private function checkNew(array $node, array &$warnings): ?string { $objectId = $node['object_id'] ?? null; switch ($node['type'] ?? null) { case 'post': $post = is_int($objectId) ? get_post($objectId) : null; if (! $post instanceof WP_Post || in_array($post->post_type, self::NON_LINKABLE, true)) { return 'object_id muss ein vorhandener Eintrag sein.'; } if ($post->post_status !== 'publish') { $warnings[] = sprintf( '"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.', $post->post_title ); } return null; case 'taxonomy': return is_int($objectId) && get_term($objectId) instanceof WP_Term ? null : 'object_id muss ein vorhandener Begriff sein.'; case 'custom': if (! $this->validUrl($node['url'] ?? null)) { return 'Individueller Link braucht eine gültige url (/pfad, #anker, http(s), mailto, tel).'; } return is_string($node['title'] ?? null) && trim($node['title']) !== '' ? null : 'Individueller Link braucht einen title.'; default: return 'type muss post, taxonomy oder custom sein.'; } } private function validUrl(mixed $url): bool { if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) { return false; } if (str_starts_with($url, '/') || str_starts_with($url, '#')) { return true; } $scheme = wp_parse_url($url, PHP_URL_SCHEME); return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true); } /** * @param list> $nodes * @param array $current * @param array $seen * @param array{created: int, updated: int, removed: int} $stats */ private function writeNodes( int $menuId, array $nodes, int $parentId, array $current, int &$position, array &$seen, array &$stats ): void { foreach ($nodes as $node) { $position++; $existing = isset($node['id']) ? $current[(int) $node['id']] : null; $args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node); if (isset($node['title'])) { $args['menu-item-title'] = (string) $node['title']; } if (isset($node['url'])) { $args['menu-item-url'] = esc_url_raw((string) $node['url']); } $args['menu-item-parent-id'] = $parentId; $args['menu-item-position'] = $position; $args['menu-item-status'] = 'publish'; $itemId = wp_update_nav_menu_item($menuId, $existing !== null ? (int) $existing->ID : 0, $args); if (is_wp_error($itemId)) { throw new RuntimeException('Menu item could not be saved: ' . $itemId->get_error_message()); } if ($existing !== null) { $seen[(int) $existing->ID] = true; $stats['updated']++; } else { $stats['created']++; } $children = (array) ($node['children'] ?? []); $this->writeNodes($menuId, $children, (int) $itemId, $current, $position, $seen, $stats); } } /** @return array */ private function argsFromItem(object $item): array { $isCustom = (string) $item->type === 'custom'; return [ 'menu-item-object-id' => (int) $item->object_id, 'menu-item-object' => (string) $item->object, 'menu-item-type' => (string) $item->type, 'menu-item-title' => (string) $item->post_title, 'menu-item-url' => $isCustom ? (string) $item->url : '', 'menu-item-description' => (string) $item->description, 'menu-item-attr-title' => (string) $item->attr_title, 'menu-item-target' => (string) $item->target, 'menu-item-classes' => implode(' ', array_filter((array) $item->classes)), 'menu-item-xfn' => (string) $item->xfn, ]; } /** * @param array $node * @return array */ private function argsFromNode(array $node): array { $objectId = (int) ($node['object_id'] ?? 0); return match ($node['type']) { 'post' => [ 'menu-item-type' => 'post_type', 'menu-item-object' => (string) get_post_type($objectId), 'menu-item-object-id' => $objectId, ], 'taxonomy' => [ 'menu-item-type' => 'taxonomy', 'menu-item-object' => (string) get_term($objectId)->taxonomy, 'menu-item-object-id' => $objectId, ], default => ['menu-item-type' => 'custom', 'menu-item-url' => ''], }; } } ``` `src/Integrations/WordPress/Menus/MenuSetItemsAbility.php`: ```php 'object', 'properties' => [ 'menu_id' => ['type' => 'integer', 'minimum' => 1], 'items' => ['type' => 'array', 'items' => ['type' => 'object']], 'operations' => [ 'type' => 'array', 'minItems' => 1, 'items' => [ 'type' => 'object', 'properties' => [ 'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']], 'id' => ['type' => 'integer', 'minimum' => 1], 'item' => ['type' => 'object'], 'parent_id' => ['type' => 'integer', 'minimum' => 0], 'position' => ['type' => 'integer', 'minimum' => 0], ], 'required' => ['op'], 'additionalProperties' => false, ], ], 'confirm' => ['type' => 'boolean'], ], 'required' => ['menu_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { $tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()]; return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']); } public function annotations(): array { return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; } public function isDirect(): bool { return false; } public function permission(array $input): bool|WP_Error { return current_user_can('edit_theme_options') ? true : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); } public function execute(array $input): mixed { $menuId = (int) ($input['menu_id'] ?? 0); $menu = wp_get_nav_menu_object($menuId); if (! is_object($menu)) { return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); } if (array_key_exists('items', $input) === array_key_exists('operations', $input)) { return Errors::invalidInput( 'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.', ['items' => 'oder operations'] ); } $currentItems = wp_get_nav_menu_items($menuId); $current = []; foreach (is_array($currentItems) ? $currentItems : [] as $item) { $current[(int) $item->ID] = $item; } $previous = MenuTree::build(array_values($current)); $tree = array_key_exists('items', $input) ? (array) $input['items'] : $this->operations->apply($previous, (array) $input['operations']); if ($tree instanceof WP_Error) { return $tree; } $plan = $this->writer->plan($tree, $current); if ($plan['errors'] !== []) { return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']); } if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) { return Errors::confirmRequired( sprintf( 'Folgende Menüeinträge würden entfernt: %s.', implode(', ', array_column($plan['removals'], 'title')) ), ['removals' => $plan['removals']] ); } $stats = $this->writer->apply($menuId, $tree, $current); $after = wp_get_nav_menu_items($menuId); $summary = sprintf( 'Menü "%s": %d neu, %d geändert, %d entfernt', $menu->name, $stats['created'], $stats['updated'], $stats['removed'] ); $logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary); return ToolResult::ok($summary . '.') ->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name) ->warnings($plan['warnings']) ->logId($logId) ->with('items', MenuTree::build(is_array($after) ? $after : [])) ->with('previous_items', $previous) ->toArray(); } } ``` `src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen ```php use LIT\MCP\Integrations\WordPress\Menus\MenuOperations; use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; ``` und in `abilities()` nach `new MenuCreateAbility($this->log),` einfügen: ```php new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: PASS (lange Zeilen umbrechen). - [ ] **Step 5: Commit** ```bash git add src/Integrations/WordPress tests/Unit/Integrations/WordPress git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: declarative and operation-based menu item editing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 15: WordPress-Einstellungen – `SettingsWhitelist`, `lit/wp-settings-get`, `lit/wp-settings-update` Whitelist exakt nach Spec §6.5; Recht `manage_options`. Nach einer Änderung der Permalink-Struktur werden die Rewrite-Regeln neu erzeugt – über `$wp_rewrite->set_permalink_structure()` (setzt Option und Rewrite-Objekt gemeinsam), danach `flush_rewrite_rules()`; ein bloßes `update_option()` + Flush würde die Regeln mit der alten Struktur erzeugen. **Files:** - Create: `src/Integrations/WordPress/Settings/SettingsWhitelist.php`, `src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`, `src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php` - Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` - Test: `tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`, `tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php` **Interfaces:** - Consumes: `ChangeLog`, `ToolResult`, `JsonMap`, `Errors`. - Produces: - `SettingsWhitelist` (final): `SETTINGS` (Schlüssel → `[Gruppe, Typ, Beschreibung]`), `BLOCKED`, `GROUPS`; `current(string $key): mixed` (`WPLANG` leer → `en_US`; Zahlenfelder als int); `validate(string $key, mixed $value, array $pending): ?string` (`null` = gültig; `$pending` = alle Werte dieses Aufrufs, für Abhängigkeiten `show_on_front`/`page_on_front`/`page_for_posts`); `normalize(string $key, mixed $value): mixed` (`WPLANG` `en_US` → `''`). - `WpSettingsGetAbility(SettingsWhitelist $whitelist)` – `lit/wp-settings-get`, Input `{group?}`, Output `{settings: list<{key, group, type, value, description}>}`; indirekt; readonly. - `WpSettingsUpdateAbility(SettingsWhitelist $whitelist, ChangeLog $log)` – `lit/wp-settings-update`, Input `{settings: {key: value}}`; alles oder nichts; Ergebnis-Extra `previous` (vorherige Werte); indirekt; idempotent. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`: ```php validate('admin_email', 'x@y.de', [])); self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', [])); } public function testStaticFrontPageNeedsAPublishedDistinctPage(): void { Functions\when('get_option')->justReturn(0); Functions\when('get_post')->alias(static fn (int $id) => match ($id) { 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']), 6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']), default => null, }); $whitelist = new SettingsWhitelist(); self::assertNotNull($whitelist->validate('show_on_front', 'page', [])); self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5])); self::assertNull($whitelist->validate('page_on_front', 5, [])); self::assertNotNull($whitelist->validate('page_on_front', 6, [])); self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5])); self::assertNull($whitelist->validate('page_for_posts', 0, [])); } public function testFormatsLanguagesAndRanges(): void { Functions\when('get_available_languages')->justReturn(['de_DE']); Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33); $whitelist = new SettingsWhitelist(); self::assertNull($whitelist->validate('WPLANG', 'de_DE', [])); self::assertNull($whitelist->validate('WPLANG', 'en_US', [])); self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', [])); self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', [])); self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', [])); self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', [])); self::assertNull($whitelist->validate('permalink_structure', '', [])); self::assertNotNull($whitelist->validate('permalink_structure', 'postname', [])); self::assertNotNull($whitelist->validate('posts_per_page', 0, [])); self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', [])); self::assertNull($whitelist->validate('site_icon', 33, [])); self::assertNotNull($whitelist->validate('site_icon', 34, [])); self::assertSame('', $whitelist->normalize('WPLANG', 'en_US')); } public function testCurrentNormalizesLanguageAndNumbers(): void { Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]); $whitelist = new SettingsWhitelist(); self::assertSame('en_US', $whitelist->current('WPLANG')); self::assertSame(5, $whitelist->current('page_on_front')); } } ``` `tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php`: ```php justReturn('Kunde'); $result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']); self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key')); self::assertSame('discussion', $result['settings'][0]['group']); } public function testUpdateIsAllOrNothing(): void { Functions\expect('update_option')->never(); $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class))) ->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); } public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void { Functions\when('get_option')->alias(static fn (string $key) => [ 'blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => '', ][$key]); Functions\expect('update_option')->once()->with('blogname', 'Neu'); $rewrite = Mockery::mock(); $rewrite->expects('set_permalink_structure')->with('/%postname%/'); $GLOBALS['wp_rewrite'] = $rewrite; Functions\expect('flush_rewrite_rules')->once(); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30); $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ 'blogname' => 'Neu', 'blogdescription' => 'Gleich', 'permalink_structure' => '/%postname%/', ]]); self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id')); self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']); self::assertSame(30, $result['log_id']); } public function testSettingsToolsNeedManageOptions(): void { Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options'); self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code()); self::assertSame( 'lit_forbidden', (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code() ); } } ``` In `WordPressIntegrationTest` die Konstante um die beiden Einstellungs-Tools am Ende ergänzen: ```php private const EXPECTED = [ 'lit/content-types', 'lit/content-query', 'lit/content-get', 'lit/content-create', 'lit/content-update', 'lit/post-publish', 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', 'lit/post-duplicate', 'lit/post-replace-with-copy', 'lit/revisions-list', 'lit/revision-restore', 'lit/menu-list', 'lit/menu-get', 'lit/menu-create', 'lit/menu-set-items', 'lit/menu-assign-location', 'lit/wp-settings-get', 'lit/wp-settings-update', ]; ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'Settings|WordPressIntegrationTest'` Expected: FAIL. - [ ] **Step 3: Implementierung** `src/Integrations/WordPress/Settings/SettingsWhitelist.php`: ```php [group, type, description] */ public const SETTINGS = [ 'blogname' => ['general', 'string', 'Titel der Website'], 'blogdescription' => ['general', 'string', 'Untertitel der Website'], 'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'], 'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'], 'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'], 'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'], 'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'], 'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'], 'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'], 'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'], 'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'], 'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'], 'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'], 'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'], ]; public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home']; public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon']; private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon']; public function current(string $key): mixed { $value = get_option($key); if ($key === 'WPLANG') { return $value === '' || $value === false ? 'en_US' : (string) $value; } return in_array($key, self::INTEGER_KEYS, true) ? (int) $value : (string) $value; } /** * @param array $pending All values of the current call. * @return string|null Error message, null when valid. */ public function validate(string $key, mixed $value, array $pending): ?string { if (in_array($key, self::BLOCKED, true)) { return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.'; } if (! isset(self::SETTINGS[$key])) { return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.'; } return match ($key) { 'blogname', 'blogdescription' => is_string($value) && mb_strlen($value) <= 200 ? null : 'Text bis 200 Zeichen erwartet.', 'WPLANG' => $this->checkLanguage($value), 'timezone_string' => is_string($value) && in_array($value, timezone_identifiers_list(), true) ? null : 'Zeitzonen-Kennung wie Europe/Berlin erwartet.', 'date_format', 'time_format' => is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50 ? null : 'Nicht leeres Format bis 50 Zeichen erwartet.', 'show_on_front' => $this->checkShowOnFront($value, $pending), 'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending), 'posts_per_page' => is_int($value) && $value >= 1 && $value <= 100 ? null : 'Ganzzahl von 1 bis 100 erwartet.', 'default_comment_status', 'default_ping_status' => in_array($value, ['open', 'closed'], true) ? null : 'open oder closed erwartet.', 'permalink_structure' => $this->checkPermalink($value), 'site_icon' => $this->checkSiteIcon($value), }; } public function normalize(string $key, mixed $value): mixed { return $key === 'WPLANG' && $value === 'en_US' ? '' : $value; } private function checkLanguage(mixed $value): ?string { if ($value === 'en_US' || $value === '') { return null; } $installed = get_available_languages(); return is_string($value) && in_array($value, $installed, true) ? null : sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed)); } /** @param array $pending */ private function checkShowOnFront(mixed $value, array $pending): ?string { if ($value === 'posts') { return null; } if ($value !== 'page') { return 'posts oder page erwartet.'; } $front = (int) ($pending['page_on_front'] ?? get_option('page_on_front')); return $front > 0 ? null : 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).'; } /** @param array $pending */ private function checkPage(string $key, mixed $value, array $pending): ?string { if (! is_int($value) || $value < 0) { return 'Seiten-ID (0 = keine) erwartet.'; } if ($value === 0) { return null; } $page = get_post($value); if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') { return sprintf('%d ist keine veröffentlichte Seite.', $value); } $other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front'; $otherValue = (int) ($pending[$other] ?? get_option($other)); return $otherValue === $value ? 'Startseite und Beitragsseite müssen verschiedene Seiten sein.' : null; } private function checkPermalink(mixed $value): ?string { if ($value === '') { return null; } return is_string($value) && str_starts_with($value, '/') && str_contains($value, '%') && mb_strlen($value) <= 200 && preg_match('/\s/', $value) !== 1 ? null : 'Leer oder eine Struktur wie /%postname%/ erwartet.'; } private function checkSiteIcon(mixed $value): ?string { if (! is_int($value) || $value < 0) { return 'Anhang-ID (0 = keins) erwartet.'; } return $value === 0 || wp_attachment_is_image($value) ? null : sprintf('%d ist kein Bild der Mediathek.', $value); } } ``` `src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`: ```php 'object', 'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'settings' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => ['type' => 'string'], 'group' => ['type' => 'string'], 'type' => ['type' => 'string'], 'value' => ['type' => ['string', 'integer']], 'description' => ['type' => 'string'], ], 'required' => ['key', 'group', 'type', 'value', 'description'], ], ], ], 'required' => ['settings'], ]; } 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 { return current_user_can('manage_options') ? true : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); } public function execute(array $input): mixed { $group = isset($input['group']) ? (string) $input['group'] : ''; $settings = []; foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) { if ($group !== '' && $settingGroup !== $group) { continue; } $settings[] = [ 'key' => $key, 'group' => $settingGroup, 'type' => $type, 'value' => $this->whitelist->current($key), 'description' => $description, ]; } return ['settings' => $settings]; } } ``` `src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php`: ```php 'object', 'properties' => [ 'settings' => [ 'type' => 'object', 'minProperties' => 1, 'description' => 'Schlüssel → neuer Wert, z. B. {"blogname": "Praxis Muster"}.', ], ], 'required' => ['settings'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']); } 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 { return current_user_can('manage_options') ? true : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); } public function execute(array $input): mixed { $settings = is_array($input['settings'] ?? null) ? $input['settings'] : []; if ($settings === []) { return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']); } $errors = []; foreach ($settings as $key => $value) { $error = $this->whitelist->validate((string) $key, $value, $settings); if ($error !== null) { $errors[(string) $key] = $error; } } if ($errors !== []) { return Errors::invalidInput( 'Einstellungen ungültig; nichts wurde gespeichert.', $errors, 'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.' ); } $previous = []; $changed = []; foreach ($settings as $key => $value) { $key = (string) $key; $previous[$key] = $this->whitelist->current($key); if ($previous[$key] === $value) { continue; } if ($key === 'permalink_structure') { $this->setPermalinkStructure((string) $value); } else { update_option($key, $this->whitelist->normalize($key, $value)); } $changed[] = $key; } if ($changed === []) { return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.') ->with('previous', JsonMap::of($previous)) ->toArray(); } $summary = 'Einstellungen geändert: ' . implode(', ', $changed); $logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary); $result = ToolResult::ok($summary . '.'); foreach ($changed as $key) { $result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]); } return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray(); } private function setPermalinkStructure(string $structure): void { global $wp_rewrite; $wp_rewrite->set_permalink_structure($structure); flush_rewrite_rules(); } } ``` `src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen ```php use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist; use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility; use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility; ``` und `abilities()` durch die vollständige Endfassung ersetzen: ```php public function abilities(): array { $presenter = new PostPresenter($this->schema); $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); $cloner = new PostCloner(); $whitelist = new SettingsWhitelist(); return [ new ContentTypesAbility($this->schema), new ContentQueryAbility($this->schema, $presenter), 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), new PostDuplicateAbility($this->schema, $cloner, $this->log), new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), new RevisionsListAbility($this->schema), new RevisionRestoreAbility($this->schema, $this->log), new MenuListAbility(), new MenuGetAbility(), new MenuCreateAbility($this->log), new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), new MenuAssignLocationAbility($this->log), new WpSettingsGetAbility($whitelist), new WpSettingsUpdateAbility($whitelist, $this->log), ]; } ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/Integrations/WordPress tests/Unit/Integrations/WordPress git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: whitelisted WordPress settings tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 16: Anleitungen und Ende-zu-Ende-Test über HTTP Die Anleitungen `content-types` und `workflows` (Spec §9.2) beschreiben die neuen Tools für Claude; der Ende-zu-Ende-Test ruft jede Ability dieses Plans über den echten OAuth-Endpunkt auf. Da WordPress jede Ausgabe gegen das `outputSchema` prüft, ist das zugleich der automatische Output-Schema-Test aus Spec §11.4. **Files:** - Create: `docs/guides/content-types.md`, `docs/guides/workflows.md`, `tests/Integration/lib.sh`, `tests/Integration/wp-basis-smoke.sh` - Modify: `docs/guides/overview.md` (Abschnitt „Verfügbare Werkzeuge“ ersetzen) **Interfaces:** - Consumes: alle Abilities der Tasks 6–15; `tests/Integration/mint-token.php`, `tests/Integration/pods-fixture.php`. - Produces: `tests/Integration/lib.sh` mit `fail`, `pass`, `cleanup_app_passwords_for `, `connect ` (setzt `TOKEN`, `SESSION`, `INIT_RESPONSE`), `rpc `, `tool ` (gibt `structuredContent` oder `{"__error": ""}` aus), `ability ` (über `mcp-adapter-execute-ability`; gibt das Ergebnis oder `{"__error": …}` aus), `error_code ` (liefert z. B. `rev_mismatch`) – Etappe 1b nutzt dieselben Helfer. - [ ] **Step 1: Anleitungen** `docs/guides/content-types.md`: ```markdown # Inhalte, Felder und Status ## Inhaltstypen erkennen - `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit Taxonomien, Anzahl und ob Divi dafür aktiv ist. - `lit-content-types` mit `post_type` liefert zusätzlich alle Felder: Schlüssel, Typ, Pflicht, Auswahlwerte (`choices`), Beziehungsziel (`related`) und Schreibstatus: - `writable` – sicher beschreibbar (Pods-Feld oder registriertes Meta-Feld), - `readonly` – nicht beschreibbar, - `unknown` – vorhandener Meta-Schlüssel ohne bekannten Typ; Schreiben geht, der Wert wird unverändert gespeichert (Warnung in der Antwort). ## Lesen und Suchen - `lit-content-query`: Filter `status`, `search`, `taxonomy` (`{taxonomy, terms: [IDs oder Slugs]}`), `field` (`{key, value, compare}`), `date_after`/`date_before` (JJJJ-MM-TT), `parent`; Blättern mit `next_cursor` → `cursor`. - `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. Bei Divi-Seiten fehlt `content`; `hints` verweist auf `lit-divi-outline`. ## Anlegen und Ändern - `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). - `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` mitgeben – hat jemand parallel gespeichert, kommt `rev_mismatch`: neu lesen, Änderung wiederholen. - Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder Namen; unbekannte Namen werden angelegt, falls erlaubt). - Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden. - Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek. ## Status - Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`, `lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen Wunsch des Nutzers verwenden. - `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. ## Sicher überarbeiten 1. `lit-post-duplicate` → Entwurfskopie. 2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen. 3. `lit-post-replace-with-copy` mit `confirm: true` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben; vorher entsteht eine Revision). Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` → `lit-revision-restore` mit `confirm: true`. Revisionen umfassen Titel, Inhalt und Auszug – Felder und Begriffe nicht. ``` `docs/guides/workflows.md`: ```markdown # Abläufe ## Neue Website aufsetzen 1. `lit-site-status` und `lit-content-types` lesen. 2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache, Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte. 3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`. 4. Startseite: Seite veröffentlichen (nach Rückfrage), dann `{"settings": {"page_on_front": , "show_on_front": "page"}}`. 5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`. 6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. ## Menüs pflegen - `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an `lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`). - Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`, `{op: "move", id, parent_id?, position?}`, `{op: "remove", id}`. - Fallen Einträge weg, verlangt das Tool `confirm: true`; `previous_items` erlaubt das Zurückbauen. ## Einträge eines eigenen Inhaltstyps (Pods) 1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele. 2. `lit-content-create` mit allen Pflichtfeldern in `fields`. 3. Beziehungen über IDs (vorher mit `lit-content-query` suchen). ``` `docs/guides/overview.md`: den Abschnitt ab `## Verfügbare Werkzeuge (Etappe 0)` bis zum Dateiende ersetzen durch: ```markdown ## Werkzeuge Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`). Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. ## Rückmeldungen - Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs. - Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. - `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. ``` - [ ] **Step 2: Gemeinsame Shell-Helfer** `tests/Integration/lib.sh`: ```bash #!/usr/bin/env bash # Shared helpers for the MCP integration scripts. Source it from a script that # runs INSIDE the DDEV web container with /var/www/html as working directory. # Requires jq and curl (both present in the DDEV web image). PLUGIN=wp-content/plugins/WP-LIT-MCP ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server TOKEN="" SESSION="" INIT_RESPONSE="" REQUEST_ID=100 fail() { echo "FAIL: $*"; exit 1; } pass() { echo "ok - $*"; } # Removes every "lit-mcp-integration-test" application password of a user. cleanup_app_passwords_for() { # $1 = user_login local login="$1" [ -n "$login" ] || return 0 local escaped=${login//\\/\\\\} escaped=${escaped//\'/\\\'} wp eval " \$u = get_user_by('login', '${escaped}'); if (\$u) { foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) { if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') { WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']); } } } " >/dev/null 2>&1 || true } # Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and # INIT_RESPONSE (raw initialize response incl. headers). connect() { # $1 = user_login TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1") local init init=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SESSION=$(echo "$init" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) INIT_RESPONSE="$init" echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init" } # Raw JSON-RPC call; prints the response body. rpc() { # $1 = method, $2 = params JSON REQUEST_ID=$((REQUEST_ID + 1)) local body body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}') local session_header=() [ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") curl -s --max-time 60 -X POST "$ENDPOINT" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ "${session_header[@]}" -d "$body" } # Calls a tool; prints structuredContent, or {"__error": ""} for isError results. tool() { # $1 = tool name, $2 = arguments JSON local response response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')") echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response" if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then echo "$response" | jq -c '{__error: .result.content[0].text}' else echo "$response" | jq -c '.result.structuredContent' fi } # Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}. ability() { # $1 = ability name (lit/…), $2 = parameters JSON local out out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')") if echo "$out" | jq -e 'has("__error")' >/dev/null; then echo "$out" elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then echo "$out" | jq -c '.data' else echo "$out" | jq -c '{__error: .error}' fi } # Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "". error_code() { # $1 = result JSON echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")' } ``` - [ ] **Step 3: Ende-zu-Ende-Skript** `tests/Integration/wp-basis-smoke.sh`: ```bash #!/usr/bin/env bash # End-to-end checks of the Etappe-1a tools (content, status, copies, revisions, # menus, WordPress settings, change log) through the OAuth-protected endpoint. # Runs INSIDE the DDEV web container: # cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh # Temporarily activates the installed Pods plugin (restored afterwards) and # restores every setting/menu location it changes. set -euo pipefail cd /var/www/html # shellcheck source=lib.sh source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) PODS_WAS_ACTIVE=no wp plugin is-active pods && PODS_WAS_ACTIVE=yes ORIGINAL_DESCRIPTION=$(wp option get blogdescription) ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());') CREATED_POSTS=() MENU_ID="" cleanup() { for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1 || true done [ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \ 'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true [ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true cleanup_app_passwords_for "$ADMIN_LOGIN" } trap cleanup EXIT wp plugin activate pods >/dev/null wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null connect "$ADMIN_LOGIN" # 1. Tool list: direct tools visible with annotations, the rest only via meta-tools LIST=$(rpc tools/list '{}') for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" done echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \ && fail "lit-post-trash must only be reachable via execute-ability" echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \ || fail "lit-content-get lacks readOnlyHint" pass "tools/list: direct content tools with annotations, indirect status tools" # 2. Schema detection incl. Pods TYPES=$(tool lit-content-types '{}') echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES" PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}') echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \ || fail "Pods field role missing: $PERSON_TYPE" echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \ || fail "Pods choices missing: $PERSON_TYPE" pass "content-types lists post types and Pods fields" # 3. Create, read, update with rev lock CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"

Hallo

"}') PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') [ -n "$PAGE_ID" ] || fail "content-create: $CREATED" CREATED_POSTS+=("$PAGE_ID") [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft" REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}") [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE" UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"

Neu

\"}") echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status" pass "content create/get/update, rev lock, status unchanged" # 4. Pods entries: required field enforced, values written through Pods MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}') [ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING" PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}') PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty') [ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON" CREATED_POSTS+=("$PERSON_ID") tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \ || fail "Pods values not readable" pass "Pods entry with validated fields" # 5. Status tools tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published" ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft" FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00') ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled" PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}") [ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST" tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now" pass "publish/unpublish/schedule, past dates rejected" # 6. Copy and replace COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}") COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty') [ -n "$COPY_ID" ] || fail "duplicate: $COPY" CREATED_POSTS+=("$COPY_ID") tool lit-content-update "{\"post_id\":$COPY_ID,\"content\":\"

Aus Kopie

\"}" >/dev/null NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID}") [ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM" ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"confirm\":true}" \ | jq -e '.revision_id != null' >/dev/null || fail "replace-with-copy" [ "$(wp post get "$PAGE_ID" --field=post_content)" = "

Aus Kopie

" ] || fail "original not replaced" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status" [ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed" pass "duplicate and replace-with-copy (status kept, copy trashed)" # 7. Revisions REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}") OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty') [ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS" ability lit/revision-restore "{\"revision_id\":$OLDEST,\"confirm\":true}" | jq -e '.ok == true' >/dev/null \ || fail "revision-restore" pass "revisions list and restore" # 8. Trash ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \ || fail "post-trash" [ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash" pass "post-trash moves to trash" # 9. Menus MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}') MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty') [ -n "$MENU_ID" ] || fail "menu-create: $MENU" ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}" \ | jq -e '.ok == true' >/dev/null || fail "menu-set-items (tree)" TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}") CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty') [ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE" REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}") [ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE" ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \ | jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \ || fail "menu removal with confirm" LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty') if [ -n "$LOCATION" ]; then ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \ || fail "menu-assign-location" fi pass "menu create, tree, operations with confirm, location" # 10. WordPress settings (whitelist) ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \ || fail "wp-settings-get" BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}') [ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED" ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \ | jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update" [ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated" pass "WordPress settings whitelist" # 11. Change log with client name PREFIX=$(wp db prefix) ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names) [ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)" pass "change log records ability and client" echo "ALL CHECKS PASSED" ``` - [ ] **Step 4: Ende-zu-Ende-Test ausführen** Run: ```bash cd /home/janniclas/Projekte/WordPress timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh timeout 120 ddev wp plugin is-active pods || echo "pods inactive (restored)" ``` Expected: beide Skripte `ALL CHECKS PASSED`; Pods danach wieder inaktiv. Schlägt ein Schritt mit `ability_invalid_output` fehl, passt die Ausgabe nicht zum `outputSchema` – Schema oder Ausgabe korrigieren und einen Unit-Test für den Fall ergänzen. - [ ] **Step 5: Unit-Tests und Lint** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add docs/guides tests/Integration/lib.sh tests/Integration/wp-basis-smoke.sh git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end checks for the WordPress basics; content and workflow guides Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ## Self-Review (durchgeführt beim Schreiben) - **Spec-Abdeckung (Teil 1a):** §6.1 Schema inkl. Pods → Tasks 4–6; §6.2 Content-Tools, Status, Kopie/Austausch, Papierkorb, Revisionen, `lit_mcp_after_save` → Tasks 8–12; §6.4 Menüs → Tasks 13–14; §6.5 Einstellungen → Task 15; §8.1 ToolResult → Task 1; §8.2 Fehler mit hint, alles oder nichts, Stapel → Tasks 1, 7, 9; §8.3 Protokolltabelle, Aufbewahrung, Cron → Task 3 (Ansicht/Leeren in 1b); §9.1 Instructions mit Inhaltstypen → Task 6; §9.2 Guides `content-types`, `workflows` → Task 16; §11.3 Schemas, Annotations, Cursor, deterministische Tool-Reihenfolge → alle Ability-Tasks, `WordPressIntegrationTest`; §11.4 Output-Schema-Prüfung je Ability → Task 16 (WordPress validiert jede Ausgabe). Etappe-0-Rückstände: (e) Flag-Reset → Task 2, (f) `Tested up to` → Task 1. Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Sichtbarkeit, Hinweise, Aufräumen, Doku → Plan 1b. - **Bewusste Abweichungen/Präzisierungen:** Divi-Kurzform in `lit-content-create` (§6.2) kommt mit Etappe 2 (BlockTree fehlt noch); bis dahin nur Hinweis. `rev` ist für Nicht-Divi-Inhalte optional und nur in `lit-content-update` geprüft. Fehler-JSON in der Nachricht, weil Adapter 0.5 nur den Text weitergibt. Menüeinträge werden wie im WordPress-Menü-Editor endgültig entfernt (kein Papierkorb für `nav_menu_item`), aber nur mit `confirm: true` und mit `previous_items`. - **Typ-Konsistenz geprüft:** `PostGuard::editable/postType/id`, `RevisionPoint::capture/latest`, `ChangeLog::record(…, ?int $revisionId)`, `ToolResult::with()`-Extras (`original_id`, `menu_id`, `previous_menu_id`, `items`, `previous_items`, `previous`) stimmen zwischen Interfaces-Blöcken, Code und Tests überein.