diff --git a/src/Integrations/Divi/Design/DesignRights.php b/src/Integrations/Divi/Design/DesignRights.php new file mode 100644 index 0000000..038475b --- /dev/null +++ b/src/Integrations/Divi/Design/DesignRights.php @@ -0,0 +1,34 @@ +canUseVisualBuilder()) { + return Errors::forbidden( + 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.', + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } + + return null; + } +} diff --git a/src/Integrations/Divi/Design/DiviPresetsSetAbility.php b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php new file mode 100644 index 0000000..b84b034 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviPresetsSetAbility.php @@ -0,0 +1,319 @@ + 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'], + 'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'], + 'rename' => ['type' => 'string'], + 'set' => ['type' => 'object'], + 'attrs' => ['type' => 'object'], + 'from_node' => [ + 'type' => 'object', + 'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']], + 'required' => ['post_id', 'id'], + ], + 'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'], + 'group_id' => ['type' => 'string'], + 'group_presets' => ['type' => 'object'], + 'make_default' => ['type' => 'boolean'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::presets($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null) { + return $refusal; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new PresetArea($this->writer), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(PresetBook $before, array $items): DesignPlan|WP_Error + { + $composer = ($this->composer)(); + $book = $before; + $errors = []; + $invalid = []; + $targets = []; + $warnings = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $prepared = $this->prepare($book, $item, $field, $errors); + if ($prepared === null) { + continue; + } + [$scope, $existing, $source] = $prepared; + $result = $composer->compose($book, $scope, $existing, $item, $field, $source); + $invalid = array_merge($invalid, $result['invalid_paths']); + if ($result['errors'] !== [] || $result['invalid_paths'] !== []) { + $errors += $result['errors']; + continue; + } + $id = (string) $result['item']['id']; + $book = $book->with($scope, $result['item']); + if (($item['make_default'] ?? null) === true) { + $book = $book->withDefault($scope, $id); + if ($scope->kind === PresetScope::GROUP) { + $warnings[] = self::GROUP_DEFAULT_WARNING; + } + } elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) { + $book = $book->withDefault($scope, ''); + } + if ($existing !== null || $book->defaultId($scope) === $id) { + $targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id + || $book->defaultId($scope) === $id]; + } + } + + if ($invalid !== []) { + return Errors::invalidAttrPath($errors); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = PresetDiff::between($before, $book); + + return new DesignPlan( + $book->data(), + $diff, + PresetDiff::summary($diff), + $targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)), + array_values(array_unique($warnings)) + ); + } + + /** + * Scope, preset to change and source node of one item; null after noting the problem in $errors. + * + * @param array $item Names are replaced by their cleaned form. + * @param array $errors + * @return array{PresetScope, array|null, Block|null}|null + */ + private function prepare(PresetBook $book, array &$item, string $field, array &$errors): ?array + { + try { + $scope = PresetScope::fromInput($item, $field); + } catch (InvalidArgumentException $e) { + [$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => '']; + $errors[$path] = $problem; + + return null; + } + + $existing = null; + try { + if (is_string($item['preset'] ?? null)) { + $existing = $book->find($scope, $item['preset']); + if ($existing === null) { + $errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']); + + return null; + } + } + } catch (InvalidArgumentException $e) { + $errors[$field . '.preset'] = $e->getMessage(); + + return null; + } + + foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) { + if (! $applies || ! is_string($item[$key] ?? null)) { + continue; + } + $name = $this->cleanName($item[$key]); + if ($name === null) { + $errors[$field . '.' . $key] = self::NAME_RULE; + + return null; + } + $item[$key] = $name; + $taken = array_diff($book->idsNamed($scope, $name), [(string) ($existing['id'] ?? '')]); + if ($taken !== []) { + $errors[$field . '.' . $key] = sprintf( + 'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen', + implode(', ', $taken) + ); + + return null; + } + } + + $source = $this->source($scope, $item, $field, $errors); + if ($source === false) { + return null; + } + + return [$scope, $existing, $source]; + } + + /** Plain text of 1 to 100 characters, or null (ruling: preset names). */ + private function cleanName(string $name): ?string + { + $clean = sanitize_text_field($name); + $length = function_exists('mb_strlen') ? mb_strlen($clean) : strlen($clean); + + return $clean === '' || $length > self::NAME_MAX || strlen($name) > self::NAME_MAX * 4 ? null : $clean; + } + + /** + * @param array $item + * @param array $errors + */ + private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null + { + $from = $item['from_node'] ?? null; + if ($from === null) { + return null; + } + if (! is_array($from) || ! is_string($from['id'] ?? null)) { + $errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet'; + + return false; + } + + $page = $this->loader->load($from['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + $errors[$field . '.from_node.post_id'] = Errors::clientMessage($page); + + return false; + } + $block = $page->index->find($from['id']); + if ($block === null) { + $errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']); + + return false; + } + if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) { + $errors[$field . '.from_node.id'] = sprintf( + '%s ist %s, nicht %s', + $from['id'], + $block->name(), + $scope->name + ); + + return false; + } + + return $block; + } +} diff --git a/src/Integrations/Divi/Design/PresetDiff.php b/src/Integrations/Divi/Design/PresetDiff.php index e10ab9f..de94d77 100644 --- a/src/Integrations/Divi/Design/PresetDiff.php +++ b/src/Integrations/Divi/Design/PresetDiff.php @@ -39,6 +39,30 @@ final class PresetDiff return $rows; } + /** + * One German sentence for the dry run and the change log (spec §4 step 6). + * + * @param list> $diff + */ + public static function summary(array $diff): string + { + $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); + $parts = array_filter([ + isset($count['added']) ? $count['added'] . ' angelegt' : null, + isset($count['changed']) ? $count['changed'] . ' geändert' : null, + isset($count['removed']) ? $count['removed'] . ' gelöscht' : null, + ]); + $defaults = array_filter([ + isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null, + isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null, + ]); + if ($defaults !== []) { + $parts[] = 'Standard ' . implode(', ', $defaults); + } + + return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; + } + /** * @param array> $was * @param array> $is diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index d1c8546..e0fb791 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -10,7 +10,11 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Design\DesignChange; use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility; +use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; +use LIT\MCP\Integrations\Divi\Design\PresetComposer; +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; @@ -155,6 +159,13 @@ final class DiviIntegration implements Integration new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), new DiviPresetGetAbility($this->designWriter, $this->globalUsages()), + new DiviPresetsSetAbility( + $this->designWriter, + $this->designChange(), + $this->globalUsages(), + $loader, + $this->presetComposer(...) + ), ]; } @@ -217,6 +228,25 @@ final class DiviIntegration implements Integration ); } + private function designChange(): DesignChange + { + return new DesignChange(new SnapshotStore(), $this->log); + } + + private function presetComposer(): PresetComposer + { + $composition = new DiviComposition($this->modules, $this->design); + + return new PresetComposer( + $composition->composer(), + $composition->storedGuard(), + $this->designWriter->presetAttrs(...), + $this->designWriter->groupsOf(...), + $this->modules->builderVersion(), + static fn (): int => (int) floor(microtime(true) * 1000) + ); + } + private function loader(): DiviPageLoader { return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess); diff --git a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php new file mode 100644 index 0000000..4867ef6 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php @@ -0,0 +1,29 @@ +allows('canUseVisualBuilder')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::presets($writer)); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('Visual Builder', $refusal['message']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php new file mode 100644 index 0000000..d78fa57 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php @@ -0,0 +1,232 @@ + */ + private array $stored; + + private SnapshotStore $snapshots; + + protected function setUp(): void + { + parent::setUp(); + $this->stored = PresetBookTest::stored(); + $this->snapshots = Mockery::mock(SnapshotStore::class); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null, ?DesignSystemWriter $writer = null): DiviPresetsSetAbility + { + $writer ??= $this->savingWriter(); + $usages ??= Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); + return $this->build($writer, $usages, $loader, $unfiltered); + } + + private function savingWriter(): DesignSystemWriter + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturnUsing(fn (): array => $this->stored); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array { + $data = ['module' => [], 'group' => []]; + foreach ($rest as $kind => $records) { + foreach ($records as $record) { + $first = $record['items'][0] ?? null; + $name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x'); + $data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')]; + } + } + + return $this->stored = $data; + }); + + return $writer; + } + + private function build(DesignSystemWriter $writer, GlobalUsages $usages, ?DiviPageLoader $loader, bool $unfiltered): DiviPresetsSetAbility + { + $composer = static fn (): PresetComposer => new PresetComposer( + ComposeFixture::composer($unfiltered), + new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(), + ComposeFixture::sanitizer($unfiltered)), + static fn (string $module, array $attrs): array => ['all' => array_map( + static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v, + $attrs + ), 'style' => [], 'render' => []], + static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'], + '5.15.1', + static fn (): int => 1 + ); + + return new DiviPresetsSetAbility( + $writer, + new DesignChange($this->snapshots, new FakeChangeLog(5)), + $usages, + $loader ?? new DiviPageLoader(DiviFixtures::schema()), + $composer + ); + } + + private static function newButton(array $extra = []): array + { + return ['items' => [$extra + ['module' => 'button', 'name' => 'CTA dunkel', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]]]]; + } + + public function testDryRunThenConfirmCreatesThePreset(): void + { + $ability = $this->ability(); + + $dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required'); + self::assertSame('added', $dry['diff'][0]['action']); + self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']); + + $this->snapshots->expects('save')->andReturn('presets-1'); + $this->snapshots->expects('attachLog'); + $result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertTrue($result['ok']); + self::assertSame('presets-1', $result['snapshot_id']); + $names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name'); + self::assertContains('CTA dunkel', $names); + } + + public function testExistingNameIsRefusedForANewPreset(): void + { + $result = self::assertToolError($this->ability()->execute(['items' => [ + ['module' => 'button', 'name' => 'CTA primär'], + ]]), 'validation_failed'); + + self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']); + } + + public function testChangingAndMakingDefault(): void + { + $dry = self::assertToolError($this->ability()->execute(['items' => [[ + 'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true, + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]], + ]]]), 'confirm_required'); + + self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action')); + } + + public function testUnknownPresetAndBadItemsAreCollected(): void + { + $result = self::assertToolError($this->ability()->execute(['items' => [ + ['module' => 'button', 'preset' => 'Fehlt'], + ['name' => 'ohne Modul'], + ]]), 'validation_failed'); + + self::assertArrayHasKey('items[0].preset', $result['fields']); + self::assertArrayHasKey('items[1]', $result['fields']); + } + + public function testEditorWithoutUnfilteredHtmlIsRefusedForRawCss(): void + { + $result = $this->ability(false)->execute(['items' => [[ + 'module' => 'button', 'name' => 'Mit CSS', + 'attrs' => ['css' => ['desktop' => ['mainElement' => 'color:red']]], + ]]]); + + self::assertNotTrue(is_array($result) && ($result['ok'] ?? false)); + self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']); + } + + public function testFromNodeReadsThePage(): void + { + $content = '' + . '' + . ''; + DiviFixtures::givenPost($content); + $loader = new DiviPageLoader(DiviFixtures::schema()); + + $dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[ + 'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 7, 'id' => 's0.r0.c0.m0'], + ]]]), 'confirm_required'); + + self::assertSame('added', $dry['diff'][0]['action']); + } + + public function testNamesAreCleanedAndLimited(): void + { + $ability = $this->ability(); + $dry = self::assertToolError($ability->execute(self::newButton(['name' => 'Fett Name'])), 'confirm_required'); + + $this->snapshots->expects('save')->andReturn('presets-1'); + $this->snapshots->expects('attachLog'); + $res = $ability->execute(self::newButton(['name' => 'Fett Name']) + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertTrue($res['ok']); + $names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name'); + self::assertContains('Fett Name', $names); + self::assertNotContains('Fett Name', $names); + + $long = self::assertToolError($this->ability()->execute(self::newButton(['name' => str_repeat('n', 101)])), 'validation_failed'); + self::assertStringContainsString('1–100 Zeichen', $long['fields']['items[0].name']); + $empty = self::assertToolError($this->ability()->execute(['items' => [ + ['module' => 'button', 'preset' => 'CTA primär', 'rename' => ''], + ]]), 'validation_failed'); + self::assertStringContainsString('1–100 Zeichen', $empty['fields']['items[0].rename']); + } + + public function testNothingIsWrittenWhenOneItemHasErrors(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturnUsing(fn (): array => $this->stored); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->shouldNotReceive('savePresets'); + $this->snapshots->shouldNotReceive('save'); + $ability = $this->ability(true, null, null, $writer); + + $result = $ability->execute(['items' => [ + ['module' => 'button', 'name' => 'Gut'], + ['module' => 'button', 'name' => 'Schlecht', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'Hallo']]]], + ], 'confirm' => true, 'design_rev' => 'egal']); + + self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']); + self::assertNotEmpty(array_filter( + array_keys(Errors::fieldsOf($result)), + static fn (string $key): bool => str_starts_with($key, 'items[1]') + )); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden'); + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + + self::assertSame('lit/divi-presets-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetDiffTest.php b/tests/Unit/Integrations/Divi/Design/PresetDiffTest.php index c01d4b9..7b6f003 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetDiffTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetDiffTest.php @@ -46,4 +46,17 @@ final class PresetDiffTest extends TestCase 'action' => 'default_cleared'], ], PresetDiff::between($before, $after)); } + + public function testSummaryCountsTheActions(): void + { + self::assertSame( + 'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.', + PresetDiff::summary([ + ['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'], + ['action' => 'default_set'], ['action' => 'default_cleared'], + ]) + ); + self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']])); + self::assertSame('Presets: keine Änderung.', PresetDiff::summary([])); + } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 73f5672..8b0970a 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -32,6 +32,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-node-copy', 'lit/divi-dynamic-sources', 'lit/divi-preset-get', + 'lit/divi-presets-set', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void