From 1d59d1d2ad2a445c39f901cfad698021e64de4ce Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 00:00:30 +0200 Subject: [PATCH] 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 --- .../Menus/MenuAssignLocationAbility.php | 116 +++++++++++++++++ .../WordPress/Menus/MenuCreateAbility.php | 118 ++++++++++++++++++ .../WordPress/Menus/MenuGetAbility.php | 93 ++++++++++++++ .../WordPress/Menus/MenuListAbility.php | 93 ++++++++++++++ .../WordPress/Menus/MenuLocations.php | 46 +++++++ src/Integrations/WordPress/Menus/MenuTree.php | 101 +++++++++++++++ .../WordPress/WordPressIntegration.php | 8 ++ .../Menus/MenuAssignLocationAbilityTest.php | 57 +++++++++ .../WordPress/Menus/MenuCreateAbilityTest.php | 58 +++++++++ .../WordPress/Menus/MenuFixtures.php | 36 ++++++ .../WordPress/Menus/MenuReadAbilitiesTest.php | 61 +++++++++ .../WordPress/Menus/MenuTreeTest.php | 37 ++++++ .../WordPress/WordPressIntegrationTest.php | 4 + 13 files changed, 828 insertions(+) create mode 100644 src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php create mode 100644 src/Integrations/WordPress/Menus/MenuCreateAbility.php create mode 100644 src/Integrations/WordPress/Menus/MenuGetAbility.php create mode 100644 src/Integrations/WordPress/Menus/MenuListAbility.php create mode 100644 src/Integrations/WordPress/Menus/MenuLocations.php create mode 100644 src/Integrations/WordPress/Menus/MenuTree.php create mode 100644 tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Menus/MenuFixtures.php create mode 100644 tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php create mode 100644 tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php diff --git a/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php b/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php new file mode 100644 index 0000000..f09c734 --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php @@ -0,0 +1,116 @@ + '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); + + // The assignment is saved – a change log failure from here on is logged and reported as a warning only. + $warnings = []; + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), 'menu_location', $location, $summary), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Zuweisung ist gespeichert.', + $warnings + ); + + return ToolResult::ok($summary . '.') + ->changed($location, 'menu_location', $action, $registered[$location]) + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->with('previous_menu_id', $previous > 0 ? $previous : null) + ->toArray(); + } +} diff --git a/src/Integrations/WordPress/Menus/MenuCreateAbility.php b/src/Integrations/WordPress/Menus/MenuCreateAbility.php new file mode 100644 index 0000000..5d713fc --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuCreateAbility.php @@ -0,0 +1,118 @@ + '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); + } + + // The menu is saved – a change log failure from here on is logged and reported as a warning only. + $warnings = []; + $summary = sprintf('Menü "%s" angelegt', $name) + . ($location !== '' ? sprintf(' (Position %s)', $location) : ''); + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist angelegt.', + $warnings + ); + + return ToolResult::ok($summary . sprintf(', ID %d.', $menuId)) + ->changed((string) $menuId, 'nav_menu', 'created', $name) + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->with('menu_id', $menuId) + ->toArray(); + } +} diff --git a/src/Integrations/WordPress/Menus/MenuGetAbility.php b/src/Integrations/WordPress/Menus/MenuGetAbility.php new file mode 100644 index 0000000..8cce38d --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuGetAbility.php @@ -0,0 +1,93 @@ + '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 : []), + ]; + } +} diff --git a/src/Integrations/WordPress/Menus/MenuListAbility.php b/src/Integrations/WordPress/Menus/MenuListAbility.php new file mode 100644 index 0000000..66b89fd --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuListAbility.php @@ -0,0 +1,93 @@ + '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]; + } +} diff --git a/src/Integrations/WordPress/Menus/MenuLocations.php b/src/Integrations/WordPress/Menus/MenuLocations.php new file mode 100644 index 0000000..25e2577 --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuLocations.php @@ -0,0 +1,46 @@ + 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); + } +} diff --git a/src/Integrations/WordPress/Menus/MenuTree.php b/src/Integrations/WordPress/Menus/MenuTree.php new file mode 100644 index 0000000..ff048e1 --- /dev/null +++ b/src/Integrations/WordPress/Menus/MenuTree.php @@ -0,0 +1,101 @@ + $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', 'description' => 'Menu-item ID (0 or missing for a new item).'], + 'title' => ['type' => 'string'], + 'type' => [ + 'type' => 'string', + 'enum' => ['post', 'taxonomy', 'custom'], + 'description' => 'post/taxonomy link to object_id; custom uses url.', + ], + 'object' => [ + 'type' => 'string', + 'description' => 'Informational: WP object subtype (e.g. "page", "category", "custom").', + ], + 'object_id' => [ + 'type' => 'integer', + 'description' => 'Informational for post/taxonomy items; ignored for custom items.', + ], + 'url' => [ + 'type' => 'string', + 'description' => 'Informational for post/taxonomy items (derived by WordPress); ' + . 'authoritative only for custom items.', + ], + '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; + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 7894a4c..a87aaf8 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -13,6 +13,10 @@ use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility; use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; use LIT\MCP\Integrations\WordPress\Content\PostPresenter; use LIT\MCP\Integrations\WordPress\Content\PostWriter; +use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -66,6 +70,10 @@ final class WordPressIntegration implements Integration 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 MenuAssignLocationAbility($this->log), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php new file mode 100644 index 0000000..50c33b4 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php @@ -0,0 +1,57 @@ +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()); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php new file mode 100644 index 0000000..c7dd4bb --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php @@ -0,0 +1,58 @@ +justReturn(['primary-menu' => 'Hauptmenü']); + Functions\expect('wp_create_nav_menu')->never(); + + $ability = new MenuCreateAbility(Mockery::mock(ChangeLog::class)); + $result = $ability->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']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuFixtures.php b/tests/Unit/Integrations/WordPress/Menus/MenuFixtures.php new file mode 100644 index 0000000..80e9e4a --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuFixtures.php @@ -0,0 +1,36 @@ + $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' => '', + ]; + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php new file mode 100644 index 0000000..94beae2 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php @@ -0,0 +1,61 @@ +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([self::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()); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php new file mode 100644 index 0000000..f0ef187 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php @@ -0,0 +1,37 @@ +