From 578ccba11865938f3565dcf88a6dcf18d02dd2a2 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 16 Oct 2026 17:27:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20PresetScope=20und=20PresetBook=20?= =?UTF-8?q?=E2=80=93=20Preset-Bestand=20im=20Divi-Speicherformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- src/Integrations/Divi/Design/PresetBook.php | 236 ++++++++++++++++++ src/Integrations/Divi/Design/PresetScope.php | 63 +++++ .../Divi/Design/PresetBookTest.php | 124 +++++++++ .../Divi/Design/PresetScopeTest.php | 48 ++++ 4 files changed, 471 insertions(+) create mode 100644 src/Integrations/Divi/Design/PresetBook.php create mode 100644 src/Integrations/Divi/Design/PresetScope.php create mode 100644 tests/Unit/Integrations/Divi/Design/PresetBookTest.php create mode 100644 tests/Unit/Integrations/Divi/Design/PresetScopeTest.php diff --git a/src/Integrations/Divi/Design/PresetBook.php b/src/Integrations/Divi/Design/PresetBook.php new file mode 100644 index 0000000..3961f46 --- /dev/null +++ b/src/Integrations/Divi/Design/PresetBook.php @@ -0,0 +1,236 @@ +: {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>}>, + * group: array>}>} $data */ + private function __construct(private array $data) + { + } + + /** @param array|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 */ + 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> 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|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 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 $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 + */ + 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>}>, + * group: list>}>} */ + 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; + } +} diff --git a/src/Integrations/Divi/Design/PresetScope.php b/src/Integrations/Divi/Design/PresetScope.php new file mode 100644 index 0000000..5981b35 --- /dev/null +++ b/src/Integrations/Divi/Design/PresetScope.php @@ -0,0 +1,63 @@ +) or an option group preset + * ("group" + divi/). 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 $item An input entry with exactly one of "module" or "group". + * @throws InvalidArgumentException ": " 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; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetBookTest.php b/tests/Unit/Integrations/Divi/Design/PresetBookTest.php new file mode 100644 index 0000000..0b8faba --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetBookTest.php @@ -0,0 +1,124 @@ + 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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetScopeTest.php b/tests/Unit/Integrations/Divi/Design/PresetScopeTest.php new file mode 100644 index 0000000..770cdfa --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetScopeTest.php @@ -0,0 +1,48 @@ + '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}> */ + 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 $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()); + } + } +}