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:
co-authored by
Claude Sonnet 5.5
parent
cc20100cbc
commit
341782a34f
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user