diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ebe14a2..de64acd 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -21,7 +21,7 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, `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`. +`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`. 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 diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 221f67a..82986f1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -116,8 +116,11 @@ final class DiviIntegration implements Integration ]; } - /** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */ - public function library(): LibraryIntegration + /** + * The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access; + * $diviWritable is whether lit/divi-insert is enabled (lit-library-insert follows it, audit M2). + */ + public function library(?Closure $diviWritable = null): LibraryIntegration { $loader = $this->loader(); @@ -128,7 +131,8 @@ final class DiviIntegration implements Integration $this->writer($loader), new DiviSaver(), $this->log, - $this->modules + $this->modules, + $diviWritable ); } diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index 83932b3..271715a 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -148,6 +148,16 @@ class LibraryAccess return $this->roles->refusal('edit_global_library'); } + /** + * Inserting from the library into a page (E3): canList(), the role option add_library (Divi's + * "Aus der Bibliothek hinzufügen") and a group "Divi-Bibliothek" that is not read-only or + * disabled (E9). Reading the entry itself stays item() (ruling P5). + */ + public function canInsert(): ?WP_Error + { + return $this->canList() ?? $this->roles->refusal('add_library') ?? $this->writableGate(); + } + /** Refusal while the Tools tab keeps this connection from writing to the library. */ public function writableGate(): ?WP_Error { diff --git a/src/Integrations/Divi/Library/LibraryInsertAbility.php b/src/Integrations/Divi/Library/LibraryInsertAbility.php new file mode 100644 index 0000000..08462c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryInsertAbility.php @@ -0,0 +1,271 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'], + 'mode' => ['type' => 'string', 'enum' => self::MODES], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'library_id'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + protected function precheck(array $input): ?WP_Error + { + $refusal = $this->access->canInsert(); + if ($refusal !== null) { + return $refusal; + } + + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $mode = $input['mode'] ?? null; + if ($mode !== null && ! in_array($mode, self::MODES, true)) { + return Errors::invalidInput('mode ist „copy“ oder „global“; nichts wurde gespeichert.', [ + 'mode' => 'copy oder global', + ]); + } + + $item = $this->item($input['library_id'] ?? null); + if ($item instanceof WP_Error) { + return $item; + } + + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + $mode = is_string($mode) ? $mode : ($global ? 'global' : 'copy'); + if ($mode === 'global' && ! $global) { + return Errors::invalidInput( + 'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.', + ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden'] + ); + } + + $blocks = $this->blocks($item, $mode === 'global', $global); + if ($blocks instanceof WP_Error) { + return $blocks; + } + + $guard = $composition->storedGuard(); + foreach ($blocks as $block) { + $problems = $guard->problems($block); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; ' + . 'nichts wurde gespeichert.', + ['library_id' => implode('; ', $problems)] + ); + } + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, $blocks); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.', + ['library_id' => $violation] + ); + } + + $change = new DiviChange(sprintf( + 'Bibliothekseintrag „%s“ (%d) %s eingefügt', + (string) $item->post_title, + $id, + $mode === 'global' ? 'als globale Instanz' : 'als Kopie' + )); + foreach ($blocks as $block) { + $change->record($block, 'inserted'); + } + if ($mode === 'global') { + $change->warning(sprintf( + 'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit ' + . 'diesem Modul (lit-divi-outline mit post_id %d).', + $id, + $id + )); + } + + return $change; + } + + /** + * The active library entry $libraryId after LibraryAccess' read checks (ruling P5: the library + * right and edit_post on the entry), or the refusal. Anything else is not found. + */ + private function item(mixed $libraryId): WP_Post|WP_Error + { + $id = PostGuard::id($libraryId); + if ($id === null) { + return Errors::invalidInput('library_id fehlt oder ist keine ID; nichts wurde gespeichert.', [ + 'library_id' => 'ID aus lit/library-list', + ]); + } + + $item = $this->access->item($id, false); + if ($item instanceof WP_Error) { + return $item; + } + + if (! $item instanceof WP_Post || (string) $item->post_status !== 'publish') { + return Errors::notFound( + sprintf('Bibliothekseintrag %d gibt es nicht (oder er ist nicht aktiv).', $id), + 'Einträge mit lit/library-list auflisten.' + ); + } + + return $item; + } + + /** @return list|WP_Error The blocks to insert. */ + private function blocks(WP_Post $item, bool $instance, bool $global): array|WP_Error + { + $id = (int) $item->ID; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + return Errors::unsupported( + sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id), + 'Den Eintrag im Divi-Builder öffnen und speichern.' + ); + } + + if ($roots === []) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id), + ['library_id' => 'leer'] + ); + } + + if ($instance) { + return $this->instance($roots, $id); + } + + return array_map( + static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(), + $roots + ); + } + + /** + * The one instance of entry $id (GlobalInstance::of). Ruling P11: never nested instances – the + * root must be a single readable node holding no global module (Divi forbids nesting, F10). + * + * @param list $roots + * @return list|WP_Error + */ + private function instance(array $roots, int $id): array|WP_Error + { + if (count($roots) !== 1) { + return Errors::invalidInput( + 'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde ' + . 'gespeichert.', + ['mode' => 'mode "copy" verwenden'] + ); + } + + $root = $roots[0]; + if ($root->isOpaque() || LibraryContent::containsGlobal($root)) { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist selbst ein globales Modul, enthält eines oder ist nicht lesbar; als ' + . 'Instanz entstünde eine Verschachtelung, die Divi nicht erlaubt. Nichts wurde gespeichert.', + $id + ), + ['mode' => 'mode "copy" verwenden oder den Eintrag im Divi-Builder prüfen'] + ); + } + + return [GlobalInstance::of($root, $id)]; + } +} diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index 34dc133..c32fd32 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use Closure; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -15,13 +16,14 @@ use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** - * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, later lit-library-insert. + * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct). * Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates – * its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the * Divi 5 builder, like the Divi group. */ final class LibraryIntegration implements Integration { + /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */ public function __construct( private LibraryAccess $access, private DiviPageLoader $loader, @@ -29,7 +31,8 @@ final class LibraryIntegration implements Integration private DiviWriter $writer, private DiviSaver $saver, private ChangeLog $log, - private DiviModules $modules + private DiviModules $modules, + private ?Closure $diviWritable = null ) { } @@ -60,6 +63,7 @@ final class LibraryIntegration implements Integration $this->log, $this->modules ), + new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable), ]; } } diff --git a/src/Plugin.php b/src/Plugin.php index 2649e96..e256ff5 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -95,7 +95,7 @@ final class Plugin new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), $divi, - $divi->library() + $divi->library($diviWritable) ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index a239cb9..7f6c4a1 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi-Bibliothek', $library->label()); self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/library-list', 'lit/library-save'], + ['lit/library-list', 'lit/library-save', 'lit/library-insert'], array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) ); foreach ($library->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index 2e425a0..3044039 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -166,4 +166,18 @@ final class LibraryAccessTest extends TestCase self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden'); self::assertNull(LibraryFixtures::access()->canSave(true)); } + + /** Inserting from the library (E3): canList(), Divi's add_library and a group that is not read-only. */ + public function testInsertingNeedsAddLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access()->canInsert()); + self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canInsert(), 'forbidden'); + + LibraryFixtures::given([], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden'); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php new file mode 100644 index 0000000..613ce6e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php @@ -0,0 +1,269 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + LibraryFixtures::given(array_merge( + [$this->page], + array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)) + )); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $diviWritable = true, bool $unfiltered = false): LibraryInsertAbility + { + $access = LibraryFixtures::access($deniedRoles); + $failures = []; + + return new LibraryInsertAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, LibraryFixtures::loader($access)), + $access, + static fn (): bool => $diviWritable + ); + } + + /** + * @param array $extra + * @return array + */ + private function input(array $extra): array + { + return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra); + } + + private static function fixtureInstance(int $id, string $end): string + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''), $this->saved[0]); + self::assertSame('g0', $result['changed'][0]['id']); + self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings'])); + } + + public function testInsertsAGlobalModuleIntoAColumn(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]); + } + + public function testUntouchedContentKeepsItsBytes(): void + { + $before = (string) $this->page->post_content; + + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + $instance = self::fixtureInstance(1059, '/-->'); + $at = (int) strpos($this->saved[0], $instance); + self::assertSame($before, substr($this->saved[0], 0, $at) . substr($this->saved[0], $at + strlen($instance))); + } + + public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1055])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString("\r", $this->saved[0]); + } + + public function testLayoutCopyInsertsEverySection(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy'])); + + self::assertCount(3, $result['changed']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + /** F10: a copy of a global entry is deglobalized like Divi's /load – its root bytes, no globalParent. */ + public function testCopyOfAGlobalEntryIsItsPlainRoot(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString('globalParent', $this->saved[0]); + self::assertStringNotContainsString('global-layout', $this->saved[0]); + self::assertSame([], $result['warnings']); + } + + /** F10: nested instances in a (stored) global entry become plain nodes in the copy. */ + public function testCopyDeglobalizesNestedInstances(): void + { + $instance = self::fixtureInstance(1059, '/-->'); + $this->givenItem(1058, '' + . '' + . $instance . '' + . ''); + + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + self::assertTrue($result['ok']); + self::assertStringNotContainsString('globalModule', $this->saved[0]); + self::assertStringContainsString(''); + foreach ( + [ + '' . $instance . '', + '' + . '' + . $instance . '' + . '', + '', + '', + ] as $content + ) { + $this->givenItem(1059, $content); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1059])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Verschachtelung', $result->get_error_message()); + } + self::assertSame([], $this->saved); + } + + public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void + { + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'link'])), 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testATrashedEntryIsNotFound(): void + { + $this->givenItem(1055, DiviFixtures::markup('stage3/library--library-abschnitt.html'), ['post_status' => 'trash']); + + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055])), 'not_found'); + self::assertSame([], $this->saved); + } + + /** Ruling P5: the entry is read through LibraryAccess – edit_post on it, not read_post. */ + public function testNeedsEditPostOnTheEntry(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && (int) ($args[0] ?? 0) === 1058) + ); + + $result = $this->ability()->execute($this->input(['library_id' => 1058])); + + self::assertToolError($result, 'forbidden'); + self::assertStringNotContainsString('[Fixture]', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testNeedsAddLibraryAndAWritableDiviGroup(): void + { + self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability(['add_library'])->execute($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported'); + self::assertToolError($this->ability([], false)->execute($this->input(['library_id' => 1058])), 'unsupported'); + self::assertTrue($this->ability()->permission($this->input(['library_id' => 1058]))); + self::assertTrue($this->ability()->isDirect()); + self::assertSame([], $this->saved); + } + + public function testTheTargetKeepsTheTreeRevLockAndStructureRules(): void + { + self::assertToolError( + $this->ability()->execute(['post_id' => 7, 'tree_rev' => 'alt', 'library_id' => 1058]), + 'rev_mismatch' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1058, 'parent' => 's0.r0.c1'])), + 'validation_failed' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's9'])), + 'validation_failed' + ); + self::assertSame([], $this->saved); + } + + public function testEditorCannotInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability()->execute($this->input(['library_id' => 1080])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAdminMayInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1080])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } +}