diff --git a/docs/guides/overview.md b/docs/guides/overview.md index f453861..7ca9f5d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -20,7 +20,8 @@ des verbundenen WordPress-Benutzers. 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`, `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-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, +`lit-divi-update`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index f64576e..a5d1da7 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -101,6 +101,24 @@ final class Errors return self::make('unsupported', $message, $hint, 400); } + /** + * Unknown Divi attribute paths (spec §5.4, §8.2). $fields names every problem of the call, + * the unknown paths with their closest known ones. + * + * @param array $fields Input path => problem. + */ + public static function invalidAttrPath(array $fields): WP_Error + { + return self::make( + 'invalid_attr_path', + 'Unbekannte Attributpfade; nichts wurde gespeichert.', + 'Vorschläge unter fields prüfen; alle gültigen Pfade eines Moduls liefert lit/divi-catalog ' + . '(direkt: lit-divi-catalog) mit module.', + 400, + ['fields' => $fields] + ); + } + /** * A write failed and was rolled back completely. * diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 81e9c93..f53107d 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -116,6 +116,7 @@ final class Composer } } + $this->overlaps($block->shortName(), $change, $field); $attrs = $this->presetAndAttrs($block->name(), $attrs, $change, $field); if (array_key_exists('label', $change)) { $attrs = $change['label'] === null || $change['label'] === '' @@ -126,6 +127,39 @@ final class Composer return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; } + /** + * Ruling S38: one change may not reach the same attribute path through "set" and "attrs" – + * composite values are replaced whole per breakpoint, so combining them would silently lose + * sub-keys. Paths overlap when equal or when one lies below the other. + * + * @param array $change + */ + private function overlaps(string $type, array $change, string $field): void + { + $set = $change['set'] ?? null; + $full = $change['attrs'] ?? null; + if (! is_array($set) || ! is_array($full) || $set === [] || $full === []) { + return; + } + + $short = $this->shortform->paths($type, array_map('strval', array_keys($set))); + foreach (array_keys($full) as $path) { + $path = (string) $path; + foreach ($short as $name => $shortPath) { + if ( + $path === $shortPath || str_starts_with($path, $shortPath . '.') + || str_starts_with($shortPath, $path . '.') + ) { + $this->errors[$field . '.attrs.' . $path] = sprintf( + 'derselbe Attributpfad wird auch über set.%s geändert; bitte als zwei Änderungen senden', + $name + ); + break; + } + } + } + } + /** Admin label for a new module: " – " or the title alone. */ public function autoLabel(string $name, string $context): string { diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index d9134ef..6caf160 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -72,6 +72,25 @@ final class ShortformWriter return ['attrs' => $attrs, 'errors' => $errors]; } + /** + * Attribute paths the given short-form fields of $type write to (unknown fields left out). + * + * @param list $names Field names. + * @return array Field => attribute path. + */ + public function paths(string $type, array $names): array + { + $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); + $paths = []; + foreach ($names as $name) { + if (isset($fields[$name]['path'])) { + $paths[$name] = (string) $fields[$name]['path']; + } + } + + return $paths; + } + /** * The short-form color check (ruling S13; Task 3 reuses it for background_color): a * placeholder or reference that is a color reference, or a plain CSS color – checked diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 52b66fa..749d7f6 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; +use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -16,14 +22,15 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; /** * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded - * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). + * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one + * DiviWriter; each binds the composition to its entry via DiviWriter::composition(). */ final class DiviIntegration implements Integration { private DiviModules $modules; private DesignSystemReader $design; - public function __construct(private ContentSchema $schema) + public function __construct(private ContentSchema $schema, private ChangeLog $log) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -42,12 +49,16 @@ final class DiviIntegration implements Integration public function abilities(): array { $loader = new DiviPageLoader($this->schema); + $composition = new DiviComposition($this->modules, $this->design); + $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); return [ new DiviOutlineAbility($loader, $this->design), new DiviNodeGetAbility($loader, $this->design), new DiviCatalogAbility($this->modules), new DiviDesignSystemAbility($this->design), + new DiviInsertAbility($writer), + new DiviUpdateAbility($writer), ]; } } diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php new file mode 100644 index 0000000..9474f3e --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -0,0 +1,130 @@ + 'object', + 'properties' => array_merge(DiviWriter::inputProperties(), [ + 'nodes' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 20, + 'items' => ['type' => 'object'], + 'description' => 'Einzufügende Knoten in Reihenfolge; Abschnitte mit rows/modules, Zeilen mit ' + . 'columns und modules, Module mit Kurzform-Feldern, items (Kind-Module) und attrs.', + ], + ], DiviWriter::positionProperties()), + 'required' => ['post_id', 'tree_rev', 'nodes'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->writer->page($input); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->writer->page($input); + if ($page instanceof WP_Error) { + return $page; + } + + $composition = $this->writer->composition($page); + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + // The nodes are checked against their parent – without a valid position there is none. + return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]); + } + + $context = DiviWriter::sectionLabel($page->index, $position['parent']); + $nodes = is_array($input['nodes'] ?? null) ? array_values($input['nodes']) : []; + if ($nodes === []) { + return Errors::invalidInput('Keine Knoten angegeben; nichts wurde gespeichert.', [ + 'nodes' => 'mindestens ein Knoten', + ]); + } + + $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context); + if ($composed['errors'] !== []) { + return $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']); + } + + $violation = $editor->insert($position, $composed['blocks']); + if ($violation !== null) { + return Errors::invalidInput( + 'Knoten passen nicht an diese Stelle; nichts wurde gespeichert.', + ['nodes' => $violation] + ); + } + + $change = new DiviChange(sprintf('%d Divi-Knoten eingefügt', count($composed['blocks']))); + foreach ($composed['blocks'] as $block) { + $change->changed($block, 'inserted'); + } + + return $this->writer->save($this->name(), $page, $change, $input); + } +} diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php new file mode 100644 index 0000000..2792428 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -0,0 +1,175 @@ + 'object', + 'properties' => array_merge(DiviWriter::inputProperties(), [ + 'changes' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 50, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'set' => ['type' => 'object', 'description' => 'Kurzform-Felder, z. B. {"text": "Neu"}.'], + 'attrs' => [ + 'type' => 'object', + 'description' => 'Vollform: Attributpfad → Breakpoint-Werte.', + ], + 'preset' => ['type' => ['string', 'null'], 'description' => 'Preset-Name; null entfernt.'], + 'label' => [ + 'type' => ['string', 'null'], + 'description' => 'Admin-Label im Divi-Builder; null entfernt.', + ], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ], + ], + ]), + 'required' => ['post_id', 'tree_rev', 'changes'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->writer->page($input); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->writer->page($input); + if ($page instanceof WP_Error) { + return $page; + } + + $changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : []; + if ($changes === []) { + return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [ + 'changes' => 'mindestens eine Änderung', + ]); + } + + $composer = $this->writer->composition($page)->composer(); + $errors = []; + $invalidPaths = false; + /** @var array $seen id => position of its first change */ + $seen = []; + /** @var list}> $updates */ + $updates = []; + foreach ($changes as $position => $change) { + $field = sprintf('changes[%d]', $position); + if (! is_array($change)) { + $errors[$field] = 'Objekt mit id erwartet'; + continue; + } + + $id = is_string($change['id'] ?? null) ? $change['id'] : ''; + if ($id !== '' && isset($seen[$id])) { + // Ruling S14: a second change of the same node would overwrite the first silently. + $errors[$field . '.id'] = sprintf( + 'Knoten "%s" steht schon in changes[%d]; alle Änderungen eines Knotens in einer Änderung bündeln', + $id, + $seen[$id] + ); + continue; + } + + $block = $id === '' ? null : $page->index->find($id); + if ($id !== '') { + $seen[$id] = $position; + } + + if ($block === null) { + $errors[$field . '.id'] = sprintf('Knoten "%s" gibt es nicht (lit-divi-outline neu lesen)', $id); + continue; + } + + $result = $composer->update($block, $change, $field); + $errors += $result['errors']; + $invalidPaths = $invalidPaths || $result['invalid_paths'] !== []; + $updates[] = [$block, $result['attrs']]; + } + + if ($errors !== []) { + return $invalidPaths + ? Errors::invalidAttrPath($errors) + : Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors); + } + + $updates = array_values(array_filter( + $updates, + static fn (array $update): bool => $update[1] !== $update[0]->attrs() + )); + $change = new DiviChange(sprintf('%d Divi-Knoten geändert', count($updates))); + foreach ($updates as [$block, $attrs]) { + // Composer::update() output: every client value already passed the choke point. + $block->setAttrs($attrs); + $change->changed($block, 'updated'); + } + + return $this->writer->save($this->name(), $page, $change, $input); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9ed09db..4de2e5d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -68,7 +68,7 @@ final class Plugin new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog), new MediaIntegration($schema, $changeLog, $limits), - new DiviIntegration($schema) + new DiviIntegration($schema, $changeLog) ); $policy = ExposurePolicy::fromOption(); diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index f3f22bd..fe47b7e 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -183,6 +183,26 @@ final class ComposerTest extends TestCase self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']); } + /** Ruling S38: one change may not reach the same attribute path through set and attrs. */ + public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void + { + $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + + $result = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu'], + 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/kontakt']]], + ], 'changes[0]'); + + self::assertStringContainsString('set.text', $result['errors']['changes[0].attrs.button.innerContent']); + self::assertSame([], $result['invalid_paths']); + + $separate = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu'], + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#ffffff']]], + ], 'changes[0]'); + self::assertSame([], $separate['errors'], 'different paths may be combined'); + } + /** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */ public function testLabelsAreCleanedOrReported(): void { diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index d007b41..6572107 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; use LIT\MCP\Abilities\Ability; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; @@ -18,11 +19,13 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system', + 'lit/divi-insert', + 'lit/divi-update', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void { - $integration = new DiviIntegration(Mockery::mock(ContentSchema::class)); + $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class)); self::assertSame('divi', $integration->id()); self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php new file mode 100644 index 0000000..0672cfb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php @@ -0,0 +1,295 @@ + Contents handed to DiviSaver::save(). */ + private array $saved = []; + + private DiviWriter $writer; + + protected function setUp(): void + { + parent::setUp(); + $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); + $this->post = DiviPageLoaderTest::givenPost($this->original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); + + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { + $this->saved[] = $content; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(4711); + + $this->writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + $composition + ); + } + + private function treeRev(): string + { + return Rev::of($this->original); + } + + private function insert(): DiviInsertAbility + { + return new DiviInsertAbility($this->writer); + } + + private function update(): DiviUpdateAbility + { + return new DiviUpdateAbility($this->writer); + } + + /** @return array The structured error the client sees (message JSON, spec §8.2). */ + private static function clientError(WP_Error $error): array + { + return (array) json_decode($error->get_error_message(), true); + } + + public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void + { + $result = $this->insert()->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'after' => 's0.r0.c1.m1', + 'nodes' => [['type' => 'text', 'content' => '

Neu

']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertCount(1, $this->saved); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + + // Byte identity: only the new block was added, every other byte is unchanged. + $inserted = ''; + self::assertSame(str_replace('' . $inserted . '', $this->saved[0]); + self::assertSame('s0', $result['changed'][0]['id']); + self::assertSame('Start', $result['changed'][0]['label']); + } + + public function testUpdateChangesSeveralNodesAllOrNothing(): void + { + $failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], + ['id' => 's0.r0.c1.m9', 'label' => 'x'], + ]]); + self::assertSame('lit_validation_failed', $failed->get_error_code()); + $fields = self::clientError($failed)['fields']; + self::assertArrayHasKey('changes[1].set.level', $fields); + self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); + self::assertSame([], $this->saved); + + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]], + ]]); + + self::assertTrue($result['ok']); + self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id')); + self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action')); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']); + self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']); + // Untouched section s1 keeps its exact bytes. + self::assertStringContainsString(substr($this->original, (int) strpos($this->original, '