diff --git a/src/Integrations/Divi/Design/DiviPresetGetAbility.php b/src/Integrations/Divi/Design/DiviPresetGetAbility.php new file mode 100644 index 0000000..4cd65cf --- /dev/null +++ b/src/Integrations/Divi/Design/DiviPresetGetAbility.php @@ -0,0 +1,169 @@ + value like lit/divi-node-get –, its nested + * group presets with names, the posts that use it (readable ones by title, the rest as a count) and the design_rev + * for a following lit/divi-presets-set. + */ +final class DiviPresetGetAbility implements Ability +{ + public function __construct(private DesignSystemWriter $writer, private GlobalUsages $usages) + { + } + + public function name(): string + { + return 'lit/divi-preset-get'; + } + + public function label(): string + { + return 'Divi-Preset lesen'; + } + + public function description(): string + { + return 'Liest ein Modul-Preset (module, z. B. "button") oder Gruppen-Preset (group, z. B. "font-body") mit ' + . 'seinen Einstellungen (set in Kurzform, attrs als Attributpfad → Wert), verschachtelten Gruppen-Presets, ' + . 'den Seiten, die es verwenden (usage), und der design_rev für lit/divi-presets-set. preset = Name oder ' + . 'ID aus lit/divi-design-system (direkt: lit-divi-design-system).'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" (Modul-Preset).'], + 'group' => ['type' => 'string', 'description' => 'Gruppe, z. B. "font-body" (Gruppen-Preset).'], + 'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'], + ], + 'required' => ['preset'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'kind' => ['type' => 'string'], + 'scope' => ['type' => 'string'], + 'default' => ['type' => 'boolean'], + 'module_name' => ['type' => 'string'], + 'group_id' => ['type' => ['string', 'null']], + 'set' => ['type' => 'object'], + 'attrs' => ['type' => 'object'], + 'group_presets' => ['type' => 'array', 'items' => ['type' => 'object']], + 'usage' => ['type' => 'object'], + 'design_rev' => ['type' => 'string'], + ], + 'required' => ['id', 'name', 'kind', 'scope', 'default', 'module_name', 'group_id', 'set', 'attrs', + 'group_presets', 'usage', 'design_rev'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Presets lesen erfordert das Recht, Beiträge zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + try { + $scope = PresetScope::fromInput($input, 'input'); + } catch (InvalidArgumentException $e) { + return Errors::invalidInput('Modul oder Gruppe fehlt bzw. ist ungültig.', ['module' => $e->getMessage()]); + } + + $book = PresetBook::fromDivi($this->writer->presets()); + $wanted = is_string($input['preset'] ?? null) ? $input['preset'] : ''; + try { + $item = $book->find($scope, $wanted); + } catch (InvalidArgumentException $e) { + return Errors::invalidInput('Der Preset-Name ist mehrdeutig.', ['preset' => $e->getMessage()]); + } + if ($item === null) { + return Errors::notFound( + sprintf('%s „%s“ gibt es nicht.', $scope->label(), $wanted), + 'Vorhandene Presets mit lit/divi-design-system (direkt: lit-divi-design-system) und module auflisten.' + ); + } + + $id = (string) $item['id']; + $isDefault = $book->defaultId($scope) === $id; + $attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : []; + $module = is_string($item['moduleName'] ?? null) ? $item['moduleName'] : $scope->name; + $usage = $this->usages->reportFor( + new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => $isDefault]]), + false + ); + + return [ + 'id' => $id, + 'name' => (string) ($item['name'] ?? $id), + 'kind' => $scope->kind, + 'scope' => $scope->name, + 'default' => $isDefault, + 'module_name' => $module, + 'group_id' => is_string($item['groupId'] ?? null) ? $item['groupId'] : null, + 'set' => JsonMap::of(ShortformMap::values(substr($module, (int) strpos($module, '/') + 1), $attrs)), + 'attrs' => JsonMap::of(AttrMap::flatten($attrs)), + 'group_presets' => $this->nested($book, $item), + 'usage' => $usage->listing(), + 'design_rev' => $book->rev(), + ]; + } + + /** + * @param array $item + * @return list}> + */ + private function nested(PresetBook $book, array $item): array + { + $rows = []; + foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $groupId => $nested) { + $groupName = is_array($nested) && is_string($nested['groupName'] ?? null) ? $nested['groupName'] : ''; + $presets = []; + foreach (is_array($nested) ? (array) ($nested['presetId'] ?? []) : [] as $presetId) { + $found = $book->items(new PresetScope(PresetScope::GROUP, $groupName))[(string) $presetId] ?? null; + $presets[] = ['id' => (string) $presetId, 'name' => (string) ($found['name'] ?? $presetId)]; + } + $rows[] = ['group_id' => (string) $groupId, 'group_name' => $groupName, 'presets' => $presets]; + } + + return $rows; + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 55eb00f..d1c8546 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -8,7 +8,9 @@ use Closure; use LIT\MCP\ChangeLog\ChangeLog; 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\DiviPresetGetAbility; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; @@ -57,6 +59,7 @@ final class DiviIntegration implements Integration private DesignSystemReader $design; private LibraryAccess $access; private ThemeBuilderAccess $themeBuilderAccess; + private DesignSystemWriter $designWriter; /** * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries @@ -84,6 +87,7 @@ final class DiviIntegration implements Integration ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); + $this->designWriter = new DesignSystemWriter(); $roles = new DiviRoles(); $this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible); $this->themeBuilderAccess = new ThemeBuilderAccess( @@ -150,6 +154,7 @@ final class DiviIntegration implements Integration new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), + new DiviPresetGetAbility($this->designWriter, $this->globalUsages()), ]; } diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php new file mode 100644 index 0000000..ca3b663 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php @@ -0,0 +1,82 @@ +allows('presets')->andReturn(PresetBookTest::stored()); + $usages ??= Mockery::mock(GlobalUsages::class); + + return new DiviPresetGetAbility($writer, $usages); + } + + public function testReadsAPresetWithItsUsage(): void + { + Functions\when('current_user_can')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->with(Mockery::type(PresetUsageQuery::class), false) + ->andReturn(new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', + 'type' => 'page']], 0, 1, [], 0, false)); + $ability = $this->ability($usages); + + self::assertTrue($ability->permission(['module' => 'text', 'preset' => 'Fließtext'])); + $result = $ability->execute(['module' => 'text', 'preset' => 'Fließtext']); + + self::assertSame(['dddddddddd', 'Fließtext', 'module', 'divi/text', true], [$result['id'], $result['name'], + $result['kind'], $result['scope'], $result['default']]); + self::assertSame([[ + 'group_id' => 'content.decoration.bodyFont', + 'group_name' => 'divi/font-body', + 'presets' => [['id' => 'gggggggggg', 'name' => 'Lesetext']], + ]], $result['group_presets']); + self::assertSame(3, $result['usage']['affected'][0]['post_id']); + self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $result['design_rev']); + self::assertFalse($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testFlattensAttrsLikeNodeGet(): void + { + Functions\when('current_user_can')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); + + $result = $this->ability($usages)->execute(['module' => 'button', 'preset' => 'aaaaaaaaaa']); + + self::assertSame(['button.x' => 1], (array) $result['attrs']); + } + + public function testUnknownAndAmbiguousPresets(): void + { + Functions\when('current_user_can')->justReturn(true); + + self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Fehlt']), 'not_found'); + $ambiguous = self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Doppelt']), + 'validation_failed'); + self::assertStringContainsString('bbbbbbbbbb, cccccccccc', $ambiguous['fields']['preset']); + self::assertToolError($this->ability()->execute(['preset' => 'x']), 'validation_failed'); + } + + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertToolError($this->ability()->permission([]), 'forbidden'); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index d7a39cb..73f5672 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -31,6 +31,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-replace-text', 'lit/divi-node-copy', 'lit/divi-dynamic-sources', + 'lit/divi-preset-get', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void