From b7c714939d22f4f982c8456ef4b135f8e4b9813e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:29:37 +0200 Subject: [PATCH] feat: group Divi-Bibliothek with lit/library-list Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 4 +- src/Integrations/Divi/DiviIntegration.php | 14 +- .../Divi/Library/LibraryIntegration.php | 50 ++++ .../Divi/Library/LibraryListAbility.php | 242 ++++++++++++++++++ src/Plugin.php | 3 +- .../Integrations/Divi/DiviIntegrationTest.php | 19 ++ .../Divi/Library/LibraryListAbilityTest.php | 200 +++++++++++++++ 7 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryIntegration.php create mode 100644 src/Integrations/Divi/Library/LibraryListAbility.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index d20f77f..6b053c4 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -23,7 +23,7 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`. -Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter +Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen („wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“ schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer @@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `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`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 5539338..4ea0410 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\Divi\Library\LibraryIntegration; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; @@ -98,7 +99,7 @@ final class DiviIntegration implements Integration public function abilities(): array { - $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); + $loader = $this->loader(); $composition = new DiviComposition($this->modules, $this->design); $writer = new DiviWriter( $loader, @@ -122,4 +123,15 @@ final class DiviIntegration implements Integration new DiviReplaceTextAbility($this->schema, $loader, $writer), ]; } + + /** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */ + public function library(): LibraryIntegration + { + return new LibraryIntegration($this->access, $this->loader(), $this->design); + } + + private function loader(): DiviPageLoader + { + return new DiviPageLoader($this->schema, new Parser(), $this->access); + } } diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php new file mode 100644 index 0000000..88c91d4 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -0,0 +1,50 @@ +access, $this->loader, $this->design, new GlobalUsages()), + ]; + } +} diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php new file mode 100644 index 0000000..b503d27 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -0,0 +1,242 @@ + 'object', + 'properties' => array_merge([ + 'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'], + 'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES], + 'scope' => ['type' => 'string', 'enum' => ['normal', 'global']], + 'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'], + 'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'], + ], Cursor::inputProperties()), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'layout_type' => ['type' => ['string', 'null']], + 'scope' => ['type' => 'string'], + 'module_type' => ['type' => ['string', 'null']], + 'categories' => ['type' => 'array', 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'items' => ['type' => 'string']], + 'modified' => ['type' => 'string'], + ], + 'required' => [ + 'id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified', + ], + ], + ], + 'next_cursor' => ['type' => ['string', 'null']], + 'outline' => ['type' => 'string'], + 'tree_rev' => ['type' => 'string'], + 'rev' => ['type' => 'string'], + 'edit_url' => ['type' => 'string'], + 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], + 'affected_count' => ['type' => 'integer'], + ], + 'required' => ['items', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + if (isset($input['id'])) { + $page = $this->entry($input['id']); + + return $page instanceof WP_Error ? $page : true; + } + + return $this->access->canList() ?? true; + } + + public function execute(array $input): mixed + { + if (isset($input['id'])) { + $page = $this->entry($input['id']); + + return $page instanceof WP_Error ? $page : $this->detail($page); + } + + $refusal = $this->access->canList(); + if ($refusal !== null) { + return $refusal; + } + + $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $filters = array_filter( + [ + 'layout_type' => $input['layout_type'] ?? null, + 'scope' => $input['scope'] ?? null, + 'search' => $input['search'] ?? null, + 'category' => $input['category'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + + return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset); + } + + /** + * One page of the entries the user may edit. The cursor counts the store's rows, so entries + * left out never shift a page; next_cursor points at the next readable entry, null when none + * follows (or the scan cap is reached with nothing left). + * + * @param array $filters + * @return array{items: list>, next_cursor: ?string} + */ + private function page(array $filters, int $limit, int $offset): array + { + $store = $this->access->store(); + $items = []; + $position = $offset; + while ($position - $offset < self::MAX_SCAN) { + $rows = $store->find($filters, $limit, $position); + foreach ($rows as $post) { + if ($this->access->canRead($post) !== null) { + ++$position; + continue; + } + if (count($items) === $limit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + } + $items[] = $store->describe($post); + ++$position; + } + if (count($rows) <= $limit) { + return ['items' => $items, 'next_cursor' => null]; + } + } + + return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + } + + /** + * The library entry $id loaded for reading. Anything that is not a library entry reads like a + * missing ID, so the tool tells nothing about other posts (ruling P12). + */ + private function entry(mixed $id): DiviPage|WP_Error + { + $postId = PostGuard::id($id); + if ($postId === null) { + return Errors::invalidInput('id ist keine gültige Eintrags-ID.', ['id' => 'positive Ganzzahl']); + } + + $item = $this->access->item($postId, false); + if (! $item instanceof WP_Post) { + return $item ?? PostGuard::notFound($postId); + } + + $page = $this->loader->load($postId, false); + if ($page instanceof WP_Error) { + return $page; + } + + return $page->isLibraryItem() ? $page : PostGuard::notFound($postId); + } + + /** @return array */ + private function detail(DiviPage $page): array + { + $item = $this->access->store()->describe($page->post); + $outline = new Outline($this->design->read()->presetNames()); + $result = [ + 'items' => [$item], + 'next_cursor' => null, + 'outline' => $outline->text($outline->rows($page->index)), + 'tree_rev' => $page->treeRev(), + 'rev' => Rev::ofPost($page->post), + 'edit_url' => $page->editUrl(), + ]; + if ($item['scope'] === 'global') { + $usages = $this->usages->of((int) $page->post->ID); + $result['affected'] = $usages['items']; + $result['affected_count'] = $usages['count']; + } + + return $result; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 498c679..2649e96 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -94,7 +94,8 @@ final class Plugin new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), - $divi + $divi, + $divi->library() ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 1987b26..1d14e80 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; +use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; @@ -48,6 +49,24 @@ final class DiviIntegrationTest extends TestCase } } + public function testProvidesTheLibraryGroup(): void + { + Functions\when('__')->returnArg(); + $library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library(); + + self::assertSame('divi-library', $library->id()); + self::assertSame('Divi-Bibliothek', $library->label()); + self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); + self::assertSame( + ['lit/library-list'], + array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) + ); + foreach ($library->abilities() as $ability) { + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + self::assertNotNull($ability->outputSchema(), $ability->name()); + } + } + public function testGivesContentCreateTheDiviShortForm(): void { $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php new file mode 100644 index 0000000..8be91ab --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -0,0 +1,200 @@ +justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + } + + /** + * @param list $deniedRoles + * @param list $ids Entries the store lists (in this order). + */ + private function ability(array $deniedRoles = [], array $ids = [1055, 1058, 1059]): LibraryListAbility + { + $store = LibraryFixtures::store(); + $store->allows('find')->andReturnUsing(static fn (array $filters, int $limit, int $offset): array => array_slice( + array_map(static fn (int $id) => LibraryFixtures::item($id), $ids), + $offset, + $limit + 1 + )); + $access = LibraryFixtures::access($deniedRoles, true, $store); + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->with(1058)->andReturn([ + 'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']], + 'count' => 1, + ]); + + return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); + } + + /** A user with every capability but edit_post on the entries $hidden. */ + private static function withoutEditPostOn(array $hidden): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array((int) ($args[0] ?? 0), $hidden, true)) + ); + } + + public function testListsAPageWithCursor(): void + { + LibraryFixtures::given([]); + + $first = $this->ability()->execute(['limit' => 2]); + $second = $this->ability()->execute(['limit' => 2, 'cursor' => $first['next_cursor']]); + + self::assertSame([1055, 1058], array_column($first['items'], 'id')); + self::assertSame(['normal', 'global'], array_column($first['items'], 'scope')); + self::assertSame('section', $first['items'][0]['layout_type']); + self::assertSame(Cursor::encode(2), $first['next_cursor']); + self::assertSame([1059], array_column($second['items'], 'id')); + self::assertNull($second['next_cursor']); + } + + public function testPassesTheFilters(): void + { + LibraryFixtures::given([]); + $store = LibraryFixtures::store(); + $store->expects('find')->with(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten'], 20, 0) + ->andReturn([]); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + Mockery::mock(GlobalUsages::class) + ); + + $result = $ability->execute(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten', 'category' => '']); + + self::assertSame(['items' => [], 'next_cursor' => null], $result); + } + + public function testDetailOfAGlobalEntryWithOutlineAndAffectedPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $result = $this->ability()->execute(['id' => 1058]); + + self::assertSame(1058, $result['items'][0]['id']); + self::assertNull($result['next_cursor']); + self::assertStringStartsWith('s0 section "Global Abschnitt"', $result['outline']); + self::assertSame(12, strlen($result['tree_rev'])); + self::assertSame(1, $result['affected_count']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $result['edit_url']); + } + + public function testDetailOfSomethingElseIsNotFoundAndTheRoleIsNeeded(): void + { + LibraryFixtures::given([LibraryFixtures::page('')]); + + self::assertToolError($this->ability()->execute(['id' => LibraryFixtures::PAGE]), 'not_found'); + self::assertToolError($this->ability(['divi_library'])->permission([]), 'forbidden'); + self::assertToolError($this->ability()->execute(['cursor' => '!!']), 'validation_failed'); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertFalse($this->ability()->isDirect()); + } + + /** Output ruling: entries without edit_post are left out – no title, no count, the cursor still pages right. */ + public function testLeavesOutEntriesTheUserMayNotEdit(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1055, 1057]); + $ids = [1054, 1055, 1056, 1057, 1058, 1059]; + + $first = $this->ability([], $ids)->execute(['limit' => 2]); + $second = $this->ability([], $ids)->execute(['limit' => 2, 'cursor' => $first['next_cursor']]); + + self::assertSame([1054, 1056], array_column($first['items'], 'id')); + self::assertNotNull($first['next_cursor']); + self::assertSame([1058, 1059], array_column($second['items'], 'id')); + self::assertNull($second['next_cursor']); + $json = (string) json_encode([$first, $second]); + self::assertStringNotContainsString('[Fixture] 1055', $json); + self::assertStringNotContainsString('[Fixture] 1057', $json); + self::assertSame(['items', 'next_cursor'], array_keys($first)); + } + + public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1058, 1059]); + + $result = $this->ability()->execute(['limit' => 1]); + + self::assertSame([1055], array_column($result['items'], 'id')); + self::assertNull($result['next_cursor']); + } + + /** Ruling P12: without the library right, an id reads exactly like a missing ID; the list says forbidden. */ + public function testWithoutTheLibraryRightAnIdIsNotFoundLikeAMissingOne(): void + { + $missing = PostGuard::notFound(1058); + $cases = [ + 'no divi_library' => [[], ['divi_library']], + 'no edit_posts' => [['edit_posts'], []], + ]; + foreach ($cases as $case => [$deniedCaps, $deniedRoles]) { + LibraryFixtures::given([LibraryFixtures::item(1058), LibraryFixtures::page('')], $deniedCaps); + $ability = $this->ability($deniedRoles); + foreach ([$ability->permission(['id' => 1058]), $ability->execute(['id' => 1058])] as $result) { + self::assertInstanceOf(WP_Error::class, $result, $case); + self::assertSame($missing->get_error_code(), $result->get_error_code(), $case); + self::assertSame($missing->get_error_message(), $result->get_error_message(), $case); + self::assertSame($missing->get_error_data(), $result->get_error_data(), $case); + } + self::assertToolError($ability->execute(['id' => 99999]), 'not_found'); + self::assertToolError($ability->execute(['id' => LibraryFixtures::PAGE]), 'not_found'); + self::assertToolError($ability->execute([]), 'forbidden'); + self::assertToolError($ability->permission([]), 'forbidden'); + } + } + + public function testAnInvalidIdIsAValidationError(): void + { + LibraryFixtures::given([]); + + self::assertToolError($this->ability()->execute(['id' => 'abc']), 'validation_failed'); + } + + public function testDetailWithoutEditPostIsForbiddenWithoutTheTitle(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058, ['post_title' => 'Geheim'])]); + self::withoutEditPostOn([1058]); + + $result = $this->ability()->execute(['id' => 1058]); + + self::assertToolError($result, 'forbidden'); + self::assertStringNotContainsString('Geheim', (string) json_encode([$result->get_error_message(), $result->get_error_data()])); + self::assertToolError($this->ability()->permission(['id' => 1058]), 'forbidden'); + } + + public function testDetailOfANormalEntryHasNoAffectedPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + + $result = $this->ability()->execute(['id' => 1055]); + + self::assertSame('normal', $result['items'][0]['scope']); + self::assertArrayNotHasKey('affected', $result); + self::assertTrue($this->ability()->permission(['id' => 1055])); + } +}