feat: GroupKeys und PresetAssignment – Preset-Stapel und Gruppen-Presets über eine Engstelle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
6546e23e7f
commit
fafa140546
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Resolves the key of a "group_presets" entry (ruling R3) to the groupId – the attribute path of the option group in
|
||||
* the module, e.g. "content.decoration.bodyFont". The key is the short name ("font-body"), the full name
|
||||
* ("divi/font-body") or the attribute path itself; a short or full name that several groups of the module share must
|
||||
* be given as path. The groups come from Divi (DesignSystemWriter::groupsOf()), never from the client. Pure PHP.
|
||||
*/
|
||||
final class GroupKeys
|
||||
{
|
||||
/** Exception code: no group of the module matches the key. */
|
||||
public const UNKNOWN = 1;
|
||||
|
||||
/** Exception code: several groups of the module have that name. */
|
||||
public const AMBIGUOUS = 2;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $groups groupId => groupName, e.g. "module.decoration.spacing" => "divi/spacing"
|
||||
* @return string The groupId.
|
||||
* @throws InvalidArgumentException German message; code UNKNOWN or AMBIGUOUS. The UNKNOWN message does not name
|
||||
* the module – the caller adds it.
|
||||
*/
|
||||
public static function resolve(array $groups, string $key): string
|
||||
{
|
||||
if (array_key_exists($key, $groups)) {
|
||||
return (string) $key;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach ($groups as $groupId => $name) {
|
||||
if ($key === $name || 'divi/' . $key === $name) {
|
||||
$paths[] = (string) $groupId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($paths === []) {
|
||||
throw new InvalidArgumentException(sprintf('keine Gruppe "%s"', $key), self::UNKNOWN);
|
||||
}
|
||||
|
||||
if (count($paths) > 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: %s.',
|
||||
$key,
|
||||
implode(', ', $paths)
|
||||
), self::AMBIGUOUS);
|
||||
}
|
||||
|
||||
return $paths[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The one place that turns the client's "preset" and "group_presets" into the attributes Divi stores on a block
|
||||
* (D7, rulings R2/R3): modulePreset (a stack of preset IDs) and groupPreset ({groupId: {presetId, groupName}}). Names
|
||||
* resolve through the preset inventory (PresetBook::find(), a name several presets share is an error), group keys
|
||||
* through GroupKeys, every ID must pass the ValueCleaner ($validId). Nothing is stored for a key with an error; the
|
||||
* caller (Composer, AssignRewrite) refuses the whole write when "errors" is not empty. The inventory is read lazily
|
||||
* and at most once. Pure PHP; Divi and WordPress enter through the closures.
|
||||
*/
|
||||
final class PresetAssignment
|
||||
{
|
||||
private const MAX_STACK = 10;
|
||||
|
||||
private ?PresetBook $loaded = null;
|
||||
|
||||
/** @var array<string, array<string, mixed>>|null Preset ID => stored item. */
|
||||
private ?array $index = null;
|
||||
|
||||
private ?PresetOverrides $overrides = null;
|
||||
|
||||
/**
|
||||
* @param Closure(): PresetBook $book The inventory (DesignSystemReader), called lazily.
|
||||
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf(): groupId => name.
|
||||
* @param Closure(string): bool $validId True when the ID passes the ValueCleaner unchanged.
|
||||
*/
|
||||
public function __construct(private Closure $book, private Closure $groupsOf, private Closure $validId)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies "preset" and "group_presets" of $spec (only the keys present) to the attributes of a $module block.
|
||||
* null (or "" for "preset") removes the assignment; a string or a list of 1–10 strings sets it.
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param array<array-key, mixed> $spec
|
||||
* @return array{attrs: array<string, mixed>, errors: array<string, string>} Error paths: "$path.preset",
|
||||
* "$path.preset.<i>" (one entry of a list), "$path.group_presets" and "$path.group_presets.<key>".
|
||||
*/
|
||||
public function apply(string $module, array $attrs, array $spec, string $path): array
|
||||
{
|
||||
$errors = [];
|
||||
if (array_key_exists('preset', $spec)) {
|
||||
$attrs = $this->stack($module, $attrs, $spec['preset'], $path . '.preset', $errors);
|
||||
}
|
||||
|
||||
if (array_key_exists('group_presets', $spec)) {
|
||||
$attrs = $this->groups($module, $attrs, $spec['group_presets'], $path . '.group_presets', $errors);
|
||||
}
|
||||
|
||||
return ['attrs' => $attrs, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes without the local values that lie on values of the presets the block names (ruling R4).
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function clearOverrides(array $attrs): array
|
||||
{
|
||||
return $this->overrides()->clear($attrs);
|
||||
}
|
||||
|
||||
/** The same instance every time, so the Warnings and clear_overrides read one inventory. */
|
||||
public function overrides(): PresetOverrides
|
||||
{
|
||||
$this->overrides ??= new PresetOverrides(fn (string $id): ?array => $this->items()[$id] ?? null);
|
||||
|
||||
return $this->overrides;
|
||||
}
|
||||
|
||||
/** @return array<string, string> Preset ID => name, module and group presets. */
|
||||
public function names(): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($this->items() as $id => $item) {
|
||||
if (is_string($item['name'] ?? null)) {
|
||||
$names[$id] = $item['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param array<string, string> $errors
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function stack(string $module, array $attrs, mixed $preset, string $path, array &$errors): array
|
||||
{
|
||||
if ($preset === null || $preset === '') {
|
||||
unset($attrs['modulePreset']);
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
$ids = $this->resolve(
|
||||
new PresetScope(PresetScope::MODULE, $module),
|
||||
$preset,
|
||||
$path,
|
||||
true,
|
||||
'leere Liste – null entfernt das Preset',
|
||||
$errors
|
||||
);
|
||||
if ($ids !== null) {
|
||||
$attrs['modulePreset'] = $ids;
|
||||
}
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param array<string, string> $errors
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function groups(string $module, array $attrs, mixed $wanted, string $path, array &$errors): array
|
||||
{
|
||||
if (! is_array($wanted) || ($wanted !== [] && array_is_list($wanted))) {
|
||||
$errors[$path] = 'Objekt Gruppe → Preset erwartet';
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
$groups = ($this->groupsOf)($module);
|
||||
$entries = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : [];
|
||||
foreach ($wanted as $key => $value) {
|
||||
$keyPath = $path . '.' . $key;
|
||||
try {
|
||||
$groupId = GroupKeys::resolve($groups, (string) $key);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$keyPath] = $e->getCode() === GroupKeys::UNKNOWN
|
||||
? sprintf('%s in %s; vorhanden: %s', $e->getMessage(), $module, self::shortNames($groups))
|
||||
: $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($entries[$groupId]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $groups[$groupId];
|
||||
$ids = $this->resolve(
|
||||
new PresetScope(PresetScope::GROUP, $name),
|
||||
$value,
|
||||
$keyPath,
|
||||
false,
|
||||
'leere Liste – null entfernt das Gruppen-Preset',
|
||||
$errors
|
||||
);
|
||||
if ($ids !== null) {
|
||||
$entries[$groupId] = ['presetId' => $ids, 'groupName' => $name];
|
||||
}
|
||||
}
|
||||
|
||||
if ($entries === []) {
|
||||
unset($attrs['groupPreset']);
|
||||
} else {
|
||||
$attrs['groupPreset'] = $entries;
|
||||
}
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name, an ID or a list of 1–10 of them to the IDs in the same order; null when anything is wrong (the reasons
|
||||
* are in $errors: one per list entry at "$path.<i>" when $perEntry, else the first one at $path).
|
||||
*
|
||||
* @param array<string, string> $errors
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function resolve(
|
||||
PresetScope $scope,
|
||||
mixed $value,
|
||||
string $path,
|
||||
bool $perEntry,
|
||||
string $emptyMessage,
|
||||
array &$errors
|
||||
): ?array {
|
||||
$single = is_string($value);
|
||||
if (! $single && ! (is_array($value) && array_is_list($value))) {
|
||||
$errors[$path] = 'Text, Liste von Texten oder null erwartet';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$entries = $single ? [$value] : $value;
|
||||
if ($entries === [] || count($entries) > self::MAX_STACK) {
|
||||
$errors[$path] = $entries === [] ? $emptyMessage : 'höchstens ' . self::MAX_STACK . ' Presets im Stapel';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
$failed = false;
|
||||
foreach ($entries as $position => $entry) {
|
||||
try {
|
||||
$ids[] = $this->idOf($scope, $entry, $ids);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$single || ! $perEntry ? $path : $path . '.' . $position] ??= $e->getMessage();
|
||||
$failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? null : $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $taken IDs of the entries before this one.
|
||||
* @throws InvalidArgumentException The German reason the entry cannot be used.
|
||||
*/
|
||||
private function idOf(PresetScope $scope, mixed $entry, array $taken): string
|
||||
{
|
||||
if (! is_string($entry)) {
|
||||
throw new InvalidArgumentException('Preset-Name oder -ID als Text erwartet');
|
||||
}
|
||||
|
||||
$found = $this->book()->find($scope, $entry);
|
||||
if ($found === null) {
|
||||
throw new InvalidArgumentException($scope->kind === PresetScope::MODULE
|
||||
? sprintf('unbekanntes Preset; vorhanden für %s: %s', $scope->name, $this->namesIn($scope))
|
||||
: sprintf('unbekanntes Gruppen-Preset "%s" (%s)', $entry, $scope->name));
|
||||
}
|
||||
|
||||
$id = (string) $found['id'];
|
||||
if (! ($this->validId)($id)) {
|
||||
throw new InvalidArgumentException(sprintf('Preset-ID "%s" ist ungültig', $id));
|
||||
}
|
||||
if (in_array($id, $taken, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Preset "%s" steht mehrfach im Stapel', $id));
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function namesIn(PresetScope $scope): string
|
||||
{
|
||||
$names = array_column($this->book()->items($scope), 'name');
|
||||
|
||||
return $names === [] ? '(keine)' : implode(', ', $names);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $groups groupId => groupName */
|
||||
private static function shortNames(array $groups): string
|
||||
{
|
||||
$names = array_unique(array_map(
|
||||
static fn (string $name): string => str_starts_with($name, 'divi/') ? substr($name, 5) : $name,
|
||||
array_values($groups)
|
||||
));
|
||||
|
||||
return $names === [] ? '(keine)' : implode(', ', $names);
|
||||
}
|
||||
|
||||
private function book(): PresetBook
|
||||
{
|
||||
$this->loaded ??= ($this->book)();
|
||||
|
||||
return $this->loaded;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
private function items(): array
|
||||
{
|
||||
if ($this->index === null) {
|
||||
$this->index = [];
|
||||
foreach ($this->book()->data() as $records) {
|
||||
foreach ($records as $record) {
|
||||
foreach ($record['items'] as $id => $item) {
|
||||
$this->index[(string) $id] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->index;
|
||||
}
|
||||
}
|
||||
@@ -199,9 +199,12 @@ final class PresetComposer
|
||||
$groups = ($this->groupsOf)($module);
|
||||
foreach ($wanted as $group => $preset) {
|
||||
$path = $field . '.group_presets.' . $group;
|
||||
$groupId = self::findGroup($groups, (string) $group);
|
||||
if ($groupId === null) {
|
||||
$errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module);
|
||||
try {
|
||||
$groupId = GroupKeys::resolve($groups, (string) $group);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$path] = $e->getCode() === GroupKeys::UNKNOWN
|
||||
? sprintf('keine Gruppe "%s" in %s', $group, $module)
|
||||
: $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
if ($preset === null) {
|
||||
@@ -226,18 +229,6 @@ final class PresetComposer
|
||||
return $nested;
|
||||
}
|
||||
|
||||
/** @param array<string, string> $groups groupId => groupName */
|
||||
private static function findGroup(array $groups, string $wanted): ?string
|
||||
{
|
||||
foreach ($groups as $groupId => $name) {
|
||||
if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) {
|
||||
return (string) $groupId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name the ability resolved and cleaned ("name", also for a rename), else the existing preset's name.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user