feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
36af846b3e
commit
578ccba118
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The preset inventory in Divi's stored format (GlobalPreset::get_data(), formats §3):
|
||||
* {module|group: {<name>: {default: id|"", items: {id: item}}}}. Immutable – with()/without()/withDefault() return a
|
||||
* new book. rev() is the design_rev of the area "presets" (spec §2). toRest() gives the REST/sync format
|
||||
* ({module|group: list<{default, items: list}>}) that GlobalPreset::prepare_data() takes. Pure PHP.
|
||||
*/
|
||||
final class PresetBook
|
||||
{
|
||||
private const KINDS = [PresetScope::MODULE, PresetScope::GROUP];
|
||||
|
||||
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** @param array{module: array<string, array{default: string, items: array<string, array<string, mixed>>}>,
|
||||
* group: array<string, array{default: string, items: array<string, array<string, mixed>>}>} $data */
|
||||
private function __construct(private array $data)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<array-key, mixed>|object $raw GlobalPreset::get_data() – groups may be stdClass. */
|
||||
public static function fromDivi(array|object $raw): self
|
||||
{
|
||||
$raw = self::plain($raw);
|
||||
$data = [PresetScope::MODULE => [], PresetScope::GROUP => []];
|
||||
foreach (self::KINDS as $kind) {
|
||||
foreach (is_array($raw[$kind] ?? null) ? $raw[$kind] : [] as $name => $record) {
|
||||
if (! is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach (is_array($record['items'] ?? null) ? $record['items'] : [] as $id => $item) {
|
||||
if (is_array($item)) {
|
||||
$items[(string) $id] = $item;
|
||||
}
|
||||
}
|
||||
$data[$kind][(string) $name] = [
|
||||
'default' => is_string($record['default'] ?? null) ? $record['default'] : '',
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function data(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function rev(): string
|
||||
{
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||||
return Rev::of('presets' . "\0" . (string) json_encode($this->data, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->data as $records) {
|
||||
foreach ($records as $record) {
|
||||
$count += count($record['items']);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> id => item */
|
||||
public function items(PresetScope $scope): array
|
||||
{
|
||||
return $this->data[$scope->kind][$scope->name]['items'] ?? [];
|
||||
}
|
||||
|
||||
public function defaultId(PresetScope $scope): string
|
||||
{
|
||||
return $this->data[$scope->kind][$scope->name]['default'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset with ID $idOrName, else the one preset named $idOrName (case-insensitive).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
* @throws InvalidArgumentException When several presets of $scope share the name (ruling S13a).
|
||||
*/
|
||||
public function find(PresetScope $scope, string $idOrName): ?array
|
||||
{
|
||||
$items = $this->items($scope);
|
||||
if (isset($items[$idOrName])) {
|
||||
return $items[$idOrName];
|
||||
}
|
||||
|
||||
$ids = $this->idsNamed($scope, $idOrName);
|
||||
if (count($ids) > 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||||
trim($idOrName),
|
||||
implode(', ', $ids)
|
||||
));
|
||||
}
|
||||
|
||||
return $ids === [] ? null : $items[$ids[0]];
|
||||
}
|
||||
|
||||
/** @return list<string> IDs of the presets of $scope named $name (case-insensitive, trimmed). */
|
||||
public function idsNamed(PresetScope $scope, string $name): array
|
||||
{
|
||||
$key = mb_strtolower(trim($name));
|
||||
$ids = [];
|
||||
foreach ($this->items($scope) as $id => $item) {
|
||||
if (is_string($item['name'] ?? null) && mb_strtolower(trim($item['name'])) === $key) {
|
||||
$ids[] = (string) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function scopeOf(string $id): ?PresetScope
|
||||
{
|
||||
foreach ($this->data as $kind => $records) {
|
||||
foreach ($records as $name => $record) {
|
||||
if (isset($record['items'][$id])) {
|
||||
return new PresetScope($kind, $name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item Needs a string "id"; added or replaced. */
|
||||
public function with(PresetScope $scope, array $item): self
|
||||
{
|
||||
$data = $this->data;
|
||||
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
|
||||
$data[$scope->kind][$scope->name]['items'][(string) $item['id']] = $item;
|
||||
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
public function withDefault(PresetScope $scope, string $id): self
|
||||
{
|
||||
$data = $this->data;
|
||||
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
|
||||
$data[$scope->kind][$scope->name]['default'] = $id;
|
||||
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/** Removes the preset; a default that pointed to it becomes "" (spec §6.4). */
|
||||
public function without(PresetScope $scope, string $id): self
|
||||
{
|
||||
$data = $this->data;
|
||||
unset($data[$scope->kind][$scope->name]['items'][$id]);
|
||||
if (($data[$scope->kind][$scope->name]['default'] ?? '') === $id) {
|
||||
$data[$scope->kind][$scope->name]['default'] = '';
|
||||
}
|
||||
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Presets that nest group preset $groupPresetId in their groupPresets (formats §3).
|
||||
*
|
||||
* @return list<array{scope: PresetScope, id: string, name: string}>
|
||||
*/
|
||||
public function nestedUsers(string $groupPresetId): array
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->data as $kind => $records) {
|
||||
foreach ($records as $name => $record) {
|
||||
foreach ($record['items'] as $id => $item) {
|
||||
foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $nested) {
|
||||
$ids = is_array($nested) ? (array) ($nested['presetId'] ?? []) : [];
|
||||
if (in_array($groupPresetId, $ids, true)) {
|
||||
$users[] = [
|
||||
'scope' => new PresetScope($kind, $name),
|
||||
'id' => (string) $id,
|
||||
'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $id,
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
/** @return array{module: list<array{default: string, items: list<array<string, mixed>>}>,
|
||||
* group: list<array{default: string, items: list<array<string, mixed>>}>} */
|
||||
public function toRest(): array
|
||||
{
|
||||
$rest = [PresetScope::MODULE => [], PresetScope::GROUP => []];
|
||||
foreach ($this->data as $kind => $records) {
|
||||
foreach ($records as $record) {
|
||||
$rest[$kind][] = ['default' => $record['default'], 'items' => array_values($record['items'])];
|
||||
}
|
||||
}
|
||||
|
||||
return $rest;
|
||||
}
|
||||
|
||||
/** A new preset ID like the Visual Builder's generateNanoUuid(): 10 characters of [0-9a-z] (formats §3). */
|
||||
public static function newId(): string
|
||||
{
|
||||
$id = '';
|
||||
for ($i = 0; $i < 10; ++$i) {
|
||||
$id .= self::ALPHABET[random_int(0, 35)];
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private static function plain(mixed $value): mixed
|
||||
{
|
||||
if (is_object($value)) {
|
||||
$value = get_object_vars($value);
|
||||
}
|
||||
|
||||
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Where a preset lives (spec §6.1): a module preset ("module" + divi/<module>) or an option group preset
|
||||
* ("group" + divi/<group>). Input takes the short ("button", "font-body") or the full name. Pure PHP.
|
||||
*/
|
||||
final class PresetScope
|
||||
{
|
||||
public const MODULE = 'module';
|
||||
public const GROUP = 'group';
|
||||
|
||||
private const NAME = '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/D';
|
||||
|
||||
public function __construct(public readonly string $kind, public readonly string $name)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $item An input entry with exactly one of "module" or "group".
|
||||
* @throws InvalidArgumentException "<field>: <problem>" for the input path.
|
||||
*/
|
||||
public static function fromInput(array $item, string $field): self
|
||||
{
|
||||
$kinds = array_values(array_filter(
|
||||
[self::MODULE, self::GROUP],
|
||||
static fn (string $kind): bool => array_key_exists($kind, $item)
|
||||
));
|
||||
if (count($kinds) !== 1) {
|
||||
throw new InvalidArgumentException($field . ': genau eines von module oder group angeben');
|
||||
}
|
||||
|
||||
$kind = $kinds[0];
|
||||
$value = $item[$kind];
|
||||
$name = is_string($value) ? (str_contains($value, '/') ? $value : 'divi/' . $value) : '';
|
||||
if (preg_match(self::NAME, $name) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'%s.%s: Name wie "button" bzw. "divi/button" (Gruppe: "font-body") erwartet',
|
||||
$field,
|
||||
$kind
|
||||
));
|
||||
}
|
||||
|
||||
return new self($kind, $name);
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return ($this->kind === self::MODULE ? 'Modul-Preset ' : 'Gruppen-Preset ') . $this->name;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->kind === $other->kind && $this->name === $other->name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class PresetBookTest extends TestCase
|
||||
{
|
||||
/** @return array<string, mixed> Stored format like GlobalPreset::get_data(). */
|
||||
public static function stored(): array
|
||||
{
|
||||
return [
|
||||
'module' => [
|
||||
'divi/button' => ['default' => '', 'items' => [
|
||||
'aaaaaaaaaa' => ['type' => 'module', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär',
|
||||
'moduleName' => 'divi/button', 'attrs' => ['button' => ['x' => 1]]],
|
||||
'bbbbbbbbbb' => ['type' => 'module', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt',
|
||||
'moduleName' => 'divi/button', 'attrs' => []],
|
||||
'cccccccccc' => ['type' => 'module', 'id' => 'cccccccccc', 'name' => 'doppelt',
|
||||
'moduleName' => 'divi/button', 'attrs' => []],
|
||||
]],
|
||||
'divi/text' => ['default' => 'dddddddddd', 'items' => [
|
||||
'dddddddddd' => ['type' => 'module', 'id' => 'dddddddddd', 'name' => 'Fließtext',
|
||||
'moduleName' => 'divi/text', 'attrs' => [],
|
||||
'groupPresets' => ['content.decoration.bodyFont' => [
|
||||
'presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body']]],
|
||||
]],
|
||||
],
|
||||
'group' => [
|
||||
'divi/font-body' => ['default' => '', 'items' => [
|
||||
'gggggggggg' => ['type' => 'group', 'id' => 'gggggggggg', 'name' => 'Lesetext',
|
||||
'groupName' => 'divi/font-body', 'groupId' => 'content.decoration.bodyFont',
|
||||
'moduleName' => 'divi/text', 'attrs' => []],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testFindsByIdOrUniqueName(): void
|
||||
{
|
||||
$book = PresetBook::fromDivi(self::stored());
|
||||
$button = new PresetScope('module', 'divi/button');
|
||||
|
||||
self::assertSame('aaaaaaaaaa', $book->find($button, 'aaaaaaaaaa')['id']);
|
||||
self::assertSame('aaaaaaaaaa', $book->find($button, 'cta PRIMÄR')['id']);
|
||||
self::assertNull($book->find($button, 'Fehlt'));
|
||||
self::assertNull($book->find(new PresetScope('module', 'divi/text'), 'aaaaaaaaaa'));
|
||||
self::assertSame(['bbbbbbbbbb', 'cccccccccc'], $book->idsNamed($button, 'DOPPELT'));
|
||||
}
|
||||
|
||||
public function testAmbiguousNameListsTheIds(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: bbbbbbbbbb, cccccccccc.'
|
||||
);
|
||||
|
||||
PresetBook::fromDivi(self::stored())->find(new PresetScope('module', 'divi/button'), 'Doppelt');
|
||||
}
|
||||
|
||||
public function testStdClassGroupsBecomeArraysAndCountCoversBothKinds(): void
|
||||
{
|
||||
$raw = self::stored();
|
||||
$raw['group'] = (object) ['divi/font-body' => (object) $raw['group']['divi/font-body']];
|
||||
$book = PresetBook::fromDivi($raw);
|
||||
|
||||
self::assertSame(5, $book->count());
|
||||
self::assertIsArray($book->data()['group']['divi/font-body']['items']);
|
||||
self::assertSame('group', $book->scopeOf('gggggggggg')?->kind);
|
||||
self::assertNull($book->scopeOf('zzzzzzzzzz'));
|
||||
self::assertSame(0, PresetBook::fromDivi(new stdClass())->count());
|
||||
}
|
||||
|
||||
public function testWithWithoutAndDefaultAreImmutable(): void
|
||||
{
|
||||
$book = PresetBook::fromDivi(self::stored());
|
||||
$button = new PresetScope('module', 'divi/button');
|
||||
$new = [
|
||||
'type' => 'module', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'moduleName' => 'divi/button', 'attrs' => [],
|
||||
];
|
||||
|
||||
$added = $book->with($button, $new)->withDefault($button, 'eeeeeeeeee');
|
||||
$removed = $added->without($button, 'eeeeeeeeee');
|
||||
|
||||
self::assertSame(5, $book->count());
|
||||
self::assertSame(6, $added->count());
|
||||
self::assertSame('eeeeeeeeee', $added->defaultId($button));
|
||||
self::assertSame('', $removed->defaultId($button), 'removing the default clears it');
|
||||
self::assertNotSame($book->rev(), $added->rev());
|
||||
self::assertSame($book->rev(), PresetBook::fromDivi(self::stored())->rev());
|
||||
self::assertSame('', $book->defaultId(new PresetScope('group', 'divi/spacing')));
|
||||
}
|
||||
|
||||
public function testNestedUsersOfAGroupPreset(): void
|
||||
{
|
||||
$users = PresetBook::fromDivi(self::stored())->nestedUsers('gggggggggg');
|
||||
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame(['dddddddddd', 'Fließtext'], [$users[0]['id'], $users[0]['name']]);
|
||||
self::assertSame('divi/text', $users[0]['scope']->name);
|
||||
}
|
||||
|
||||
public function testRestFormatListsItemsPerName(): void
|
||||
{
|
||||
$rest = PresetBook::fromDivi(self::stored())->toRest();
|
||||
|
||||
self::assertSame('dddddddddd', $rest['module'][1]['default']);
|
||||
self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb', 'cccccccccc'], array_column($rest['module'][0]['items'], 'id'));
|
||||
self::assertSame('gggggggggg', $rest['group'][0]['items'][0]['id']);
|
||||
self::assertTrue(array_is_list($rest['module']));
|
||||
}
|
||||
|
||||
public function testNewIdsLookLikeTheBuildersIds(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', PresetBook::newId());
|
||||
self::assertNotSame(PresetBook::newId(), PresetBook::newId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PresetScopeTest extends TestCase
|
||||
{
|
||||
public function testShortAndFullNames(): void
|
||||
{
|
||||
self::assertSame('divi/button', PresetScope::fromInput(['module' => 'button'], 'items[0]')->name);
|
||||
self::assertSame('module', PresetScope::fromInput(['module' => 'divi/button'], 'items[0]')->kind);
|
||||
$group = PresetScope::fromInput(['group' => 'font-body'], 'items[0]');
|
||||
self::assertSame(['group', 'divi/font-body'], [$group->kind, $group->name]);
|
||||
self::assertSame('Gruppen-Preset divi/font-body', $group->label());
|
||||
self::assertTrue($group->equals(new PresetScope('group', 'divi/font-body')));
|
||||
}
|
||||
|
||||
/** @return array<string, array{array<string, mixed>, string}> */
|
||||
public static function invalid(): array
|
||||
{
|
||||
return [
|
||||
'nothing' => [[], 'items[0]'],
|
||||
'both' => [['module' => 'button', 'group' => 'font'], 'items[0]'],
|
||||
'not a name' => [['module' => 'Button 1'], 'items[0].module'],
|
||||
'not a string' => [['group' => 5], 'items[0].group'],
|
||||
'two slashes' => [['module' => 'divi/a/b'], 'items[0].module'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider invalid
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
public function testInvalidInputNamesTheField(array $item, string $field): void
|
||||
{
|
||||
try {
|
||||
PresetScope::fromInput($item, 'items[0]');
|
||||
self::fail('expected an exception');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringStartsWith($field . ':', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user