feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:27:27 +02:00
co-authored by Claude Haiku 5.5
parent 36af846b3e
commit 578ccba118
4 changed files with 471 additions and 0 deletions
+236
View File
@@ -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;
}
}