feat: lit/divi-preset-get – Preset mit Einstellungen und Fundstellen lesen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:44:09 +02:00
co-authored by Claude Sonnet 5.5
parent cc20100cbc
commit 341782a34f
4 changed files with 257 additions and 0 deletions
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-preset-get (spec §6.2): one module or option group preset with its settings – short form where the
* module's short form reaches them, the full form as attribute path => 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<string, mixed> $item
* @return list<array{group_id: string, group_name: string, presets: list<array{id: string, name: string}>}>
*/
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;
}
}
@@ -8,7 +8,9 @@ use Closure;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; 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\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
@@ -57,6 +59,7 @@ final class DiviIntegration implements Integration
private DesignSystemReader $design; private DesignSystemReader $design;
private LibraryAccess $access; private LibraryAccess $access;
private ThemeBuilderAccess $themeBuilderAccess; private ThemeBuilderAccess $themeBuilderAccess;
private DesignSystemWriter $designWriter;
/** /**
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries * @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->modules = new DiviModules();
$this->design = new DesignSystemReader(); $this->design = new DesignSystemReader();
$this->designWriter = new DesignSystemWriter();
$roles = new DiviRoles(); $roles = new DiviRoles();
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible); $this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
$this->themeBuilderAccess = new ThemeBuilderAccess( $this->themeBuilderAccess = new ThemeBuilderAccess(
@@ -150,6 +154,7 @@ final class DiviIntegration implements Integration
new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviReplaceTextAbility($this->schema, $loader, $writer),
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
]; ];
} }
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviPresetGetAbilityTest extends TestCase
{
private function ability(?GlobalUsages $usages = null): DiviPresetGetAbility
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->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');
}
}
@@ -31,6 +31,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-replace-text', 'lit/divi-replace-text',
'lit/divi-node-copy', 'lit/divi-node-copy',
'lit/divi-dynamic-sources', 'lit/divi-dynamic-sources',
'lit/divi-preset-get',
]; ];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void