diff --git a/docs/guides/overview.md b/docs/guides/overview.md index de64acd..2cec51e 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -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`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). 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 82986f1..b3493e1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -21,6 +21,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -53,12 +54,15 @@ final class DiviIntegration implements Integration * (group "Divi-Bibliothek" writable); null = always. * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group * "Divi-Bibliothek" not disabled, ruling P9); null = always. + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy + * follows it (ruling P9, E9); null = always. */ public function __construct( private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null, - ?Closure $libraryVisible = null + ?Closure $libraryVisible = null, + private ?Closure $diviWritable = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -113,6 +117,7 @@ final class DiviIntegration implements Integration new DiviDeleteAbility($writer), new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), + new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), ]; } diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php new file mode 100644 index 0000000..cc6e8a7 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -0,0 +1,141 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'source_post_id' => ['type' => 'integer', 'minimum' => 1], + 'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'], + 'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** lit/divi-insert's Tools-tab state, then the source's own rights – in permission() and execute(). */ + protected function precheck(array $input): ?WP_Error + { + 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.' + ); + } + + $source = $this->loader->load($input['source_post_id'] ?? null, false); + + return $source instanceof WP_Error ? $source : null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) { + return Errors::revMismatch( + 'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde ' + . 'gespeichert.', + 'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.' + ); + } + + $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : ''; + $node = $source->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $source->post); + } + + $copy = $node->copy(); + $problems = $composition->storedGuard()->problems($copy); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'gespeichert.', + ['source_id' => implode('; ', $problems)] + ); + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, [$copy]); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.', + ['source_id' => $violation] + ); + } + + return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID))) + ->record($copy, 'inserted'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e256ff5..cc3f649 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -89,7 +89,13 @@ final class Plugin $libraryWritable = static function () use (&$policy): bool { return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); }; - $divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible); + $divi = new DiviIntegration( + $schema, + $changeLog, + $libraryWritable, + libraryVisible: $libraryVisible, + diviWritable: $diviWritable + ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 7f6c4a1..e5701b5 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -29,6 +29,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-delete', 'lit/divi-duplicate-node', 'lit/divi-replace-text', + 'lit/divi-node-copy', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void @@ -67,6 +68,25 @@ final class DiviIntegrationTest extends TestCase } } + /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */ + public function testNodeCopyFollowsLitDiviInsert(): void + { + $integration = new DiviIntegration( + Mockery::mock(ContentSchema::class), + FakeChangeLog::forbidden(), + diviWritable: static fn (): bool => false + ); + $copy = null; + foreach ($integration->abilities() as $ability) { + if ($ability->name() === 'lit/divi-node-copy') { + $copy = $ability; + } + } + + self::assertNotNull($copy); + self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported'); + } + public function testGivesContentCreateTheDiviShortForm(): void { $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php new file mode 100644 index 0000000..c07980b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -0,0 +1,261 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + $this->source = LibraryFixtures::page( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + ['ID' => 8, 'post_title' => 'Quelle'] + ); + LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]); + } + + private function ability( + bool $diviWritable = true, + bool $unfiltered = false, + ?LibraryAccess $access = null + ): DiviNodeCopyAbility { + $access ??= LibraryFixtures::access(); + $loader = LibraryFixtures::loader($access); + $failures = []; + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn(['items' => [], 'count' => 0]); + $globals = new GlobalEdits($access, $usages); + + return new DiviNodeCopyAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), + $loader, + static fn (): bool => $diviWritable + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge([ + 'post_id' => 7, + 'tree_rev' => Rev::of((string) $this->target->post_content), + 'source_post_id' => 8, + 'source_tree_rev' => Rev::of((string) $this->source->post_content), + 'source_id' => 's1', + 'after' => 's0', + ], $extra); + } + + private function sourceBytes(string $id): string + { + return Serializer::block( + NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find($id) + ); + } + + public function testCopiesANodeByteForByteBetweenPages(): void + { + $bytes = $this->sourceBytes('s1'); + + $result = $this->ability()->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertCount(1, $this->saved, 'only the target is written'); + self::assertStringContainsString($bytes, $this->saved[0]); + self::assertSame('s1', $result['changed'][0]['id']); + self::assertFalse($this->ability()->isDirect()); + } + + public function testLeavesTheRestOfTheTargetByteIdentical(): void + { + $original = (string) $this->target->post_content; + $bytes = $this->sourceBytes('s1'); + + $this->ability()->execute($this->input()); + + self::assertSame($original, str_replace($bytes, '', $this->saved[0])); + self::assertSame( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + (string) $this->source->post_content, + 'the source is only read' + ); + } + + public function testGlobalInstancesStayLinked(): void + { + $result = $this->ability()->execute($this->input(['source_id' => 'g0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability()->execute($this->input(['source_id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAnAdminMayCopyBuilderOnlyValues(): void + { + $this->source = LibraryFixtures::page( + '' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability(unfiltered: true)->execute($this->input(['source_id' => 's0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } + + public function testTheSourceNeedsItsOwnRights(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8) + ); + + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability()->execute($this->input()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testFollowsLitDiviInsertExposure(): void + { + $ability = $this->ability(diviWritable: false); + + self::assertToolError($ability->permission($this->input()), 'unsupported'); + self::assertToolError($ability->execute($this->input()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testCopiesIntoALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1055, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.m0', + 'after' => null, + 'parent' => 's0.r0.c0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]); + } + + public function testCopiesFromALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + public function testAHiddenLibraryIsNoSource(): void + { + $item = LibraryFixtures::item(1055); + $access = new LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + static fn (): bool => true, + static fn (): bool => false + ); + + $result = $this->ability(access: $access)->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertToolError($result, 'not_found'); + self::assertSame([], $this->saved); + } + + public function testNeverNestsAnInstanceInAGlobalEntry(): void + { + $item = LibraryFixtures::item(1058); + LibraryFixtures::given([$this->target, $this->source, $item]); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1058, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.g0', + 'after' => null, + 'parent' => 's0.r0.c0', + 'confirm' => true, + ])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('keine globalen Module', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testRefusesAPlaceThatBreaksTheStructure(): void + { + $result = $this->ability()->execute($this->input(['after' => null, 'parent' => 's0.r0.c0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testRefusesAMissingSourceId(): void + { + self::assertToolError($this->ability()->execute($this->input(['source_id' => null])), 'not_found'); + self::assertSame([], $this->saved); + } +}