feat: PresetComposer – Preset aus Kurz-/Vollform, Modul und Gruppen-Presets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bb4d9b3baf
commit
cc20100cbc
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Design;
|
||||
use ET\Builder\Framework\UserRole\UserRole;
|
||||
use ET\Builder\Packages\Conversion\Conversion;
|
||||
use ET\Builder\Packages\GlobalData\GlobalPreset;
|
||||
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
|
||||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||
@@ -51,6 +52,8 @@ class DesignSystemWriter implements DiviAdapter
|
||||
new Call(GlobalPreset::class . '::save_data', 1),
|
||||
new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
|
||||
new Call(Conversion::class . '::get_preset_attrs', 3),
|
||||
new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1),
|
||||
new Call(ModuleRegistration::class . '::get_module_settings', 1),
|
||||
new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
|
||||
],
|
||||
mirrors: [
|
||||
@@ -113,6 +116,33 @@ class DesignSystemWriter implements DiviAdapter
|
||||
return is_array($saved) ? $saved : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g.
|
||||
* "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function groupsOf(string $moduleName): array
|
||||
{
|
||||
if (! class_exists(ModuleRegistration::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$settings = ModuleRegistration::get_module_settings($moduleName);
|
||||
if ($settings === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach (GlobalPreset::get_group_preset_default_attr($settings) as $groupId => $group) {
|
||||
if (is_array($group) && is_string($group['groupName'] ?? null)) {
|
||||
$groups[(string) $groupId] = $group['groupName'];
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style
|
||||
* (styleAttrs) and render (renderAttrs: html + script).
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Builds one preset item (spec §6.3, formats §3) from the tool input: the short form ("set") and the full form
|
||||
* ("attrs") pass Composer::update() on a virtual block of the preset's module – the same choke point, placeholders
|
||||
* and S7/S26 locks as for modules (D7) –, from_node takes the preset attributes of a stored node after
|
||||
* StoredNodeGuard (E6), group_presets nest option group presets. Every resulting path must be a preset attribute
|
||||
* of the module (Divi's mapping, $presetAttrs) and, for a group preset, lie below its group. styleAttrs/renderAttrs
|
||||
* are split with the same mapping, like the Visual Builder does. Pure PHP; Divi enters through the closures.
|
||||
*/
|
||||
final class PresetComposer
|
||||
{
|
||||
private const CONTENT = 'kein Preset-Attribut (Inhalte wie Texte, Links und Bilder gehören ins Modul)';
|
||||
|
||||
/**
|
||||
* @param Closure(string, array<string, mixed>): array{all: array<string, mixed>, style: array<string, mixed>,
|
||||
* render: array<string, mixed>} $presetAttrs DesignSystemWriter::presetAttrs()
|
||||
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf()
|
||||
* @param Closure(): int $now Milliseconds, like the builder's Date.now().
|
||||
*/
|
||||
public function __construct(
|
||||
private Composer $composer,
|
||||
private StoredNodeGuard $guard,
|
||||
private Closure $presetAttrs,
|
||||
private Closure $groupsOf,
|
||||
private string $version,
|
||||
private Closure $now
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing The preset to change, null for a new one.
|
||||
* @param array<string, mixed> $input One item of lit/divi-presets-set.
|
||||
* @return array{item: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||
*/
|
||||
public function compose(
|
||||
PresetBook $book,
|
||||
PresetScope $scope,
|
||||
?array $existing,
|
||||
array $input,
|
||||
string $field,
|
||||
?Block $source
|
||||
): array {
|
||||
$errors = [];
|
||||
$module = $this->moduleOf($scope, $existing, $input, $source);
|
||||
if ($module === null) {
|
||||
$errors[$field . '.source_module'] = 'Modul des Gruppen-Presets fehlt (source_module oder from_node)';
|
||||
|
||||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
|
||||
}
|
||||
|
||||
$groupId = null;
|
||||
if ($scope->kind === PresetScope::GROUP) {
|
||||
$groupId = $this->groupIdOf($scope, $module, $existing, $input, $field, $errors);
|
||||
if ($groupId === null) {
|
||||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
|
||||
}
|
||||
}
|
||||
|
||||
$attrs = is_array($existing['attrs'] ?? null) ? $existing['attrs'] : [];
|
||||
if ($source !== null) {
|
||||
$problems = $this->guard->problems($source);
|
||||
if ($problems !== []) {
|
||||
$errors[$field . '.from_node'] = implode('; ', $problems);
|
||||
}
|
||||
$taken = ($this->presetAttrs)($module, $source->attrs())['all'];
|
||||
$attrs = array_replace_recursive($attrs, $groupId === null ? $taken : self::below($taken, $groupId));
|
||||
}
|
||||
|
||||
$change = array_intersect_key($input, ['set' => true, 'attrs' => true]);
|
||||
if ($change !== []) {
|
||||
$updated = $this->composer->update(Block::create($module, $attrs), $change, $field);
|
||||
$attrs = $updated['attrs'];
|
||||
$errors += $updated['errors'];
|
||||
if ($updated['invalid_paths'] !== []) {
|
||||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => $updated['invalid_paths']];
|
||||
}
|
||||
}
|
||||
|
||||
$split = ($this->presetAttrs)($module, $attrs);
|
||||
$allowed = AttrMap::flatten($split['all']);
|
||||
foreach (array_keys(AttrMap::flatten($attrs)) as $path) {
|
||||
if (! array_key_exists($path, $allowed)) {
|
||||
$errors[$field . '.attrs.' . $path] = self::CONTENT;
|
||||
} elseif ($groupId !== null && ! self::inGroup($path, $groupId)) {
|
||||
$errors[$field . '.attrs.' . $path] = 'gehört nicht zur Gruppe ' . $groupId;
|
||||
}
|
||||
}
|
||||
|
||||
$nested = $this->groupPresets($book, $scope, $module, $existing, $input, $field, $errors);
|
||||
$name = $this->nameOf($existing, $input, $field, $errors);
|
||||
|
||||
return [
|
||||
'item' => $this->item($scope, $existing, $name, $module, $groupId, $attrs, $split, $nested),
|
||||
'errors' => $errors,
|
||||
'invalid_paths' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
private function moduleOf(PresetScope $scope, ?array $existing, array $input, ?Block $source): ?string
|
||||
{
|
||||
if ($scope->kind === PresetScope::MODULE) {
|
||||
return $scope->name;
|
||||
}
|
||||
if (is_string($existing['moduleName'] ?? null)) {
|
||||
return $existing['moduleName'];
|
||||
}
|
||||
if ($source !== null) {
|
||||
return $source->name();
|
||||
}
|
||||
$short = $input['source_module'] ?? null;
|
||||
if (! is_string($short) || preg_match('/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/D', $short) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_contains($short, '/') ? $short : 'divi/' . $short;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, string> $errors
|
||||
*/
|
||||
private function groupIdOf(
|
||||
PresetScope $scope,
|
||||
string $module,
|
||||
?array $existing,
|
||||
array $input,
|
||||
string $field,
|
||||
array &$errors
|
||||
): ?string {
|
||||
if (is_string($existing['groupId'] ?? null)) {
|
||||
return $existing['groupId'];
|
||||
}
|
||||
|
||||
$paths = array_keys(array_filter(
|
||||
($this->groupsOf)($module),
|
||||
static fn (string $group): bool => $group === $scope->name
|
||||
));
|
||||
$wanted = $input['group_id'] ?? null;
|
||||
if (is_string($wanted) && in_array($wanted, $paths, true)) {
|
||||
return $wanted;
|
||||
}
|
||||
if (count($paths) === 1 && $wanted === null) {
|
||||
return (string) $paths[0];
|
||||
}
|
||||
|
||||
$errors[$field . '.group_id'] = $paths === []
|
||||
? sprintf('%s hat keine Gruppe %s', $module, $scope->name)
|
||||
: sprintf('mehrere Gruppen %s in %s – group_id angeben: %s', $scope->name, $module, implode(', ', $paths));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, string> $errors
|
||||
* @return array<string, array{presetId: list<string>, groupName: string}>
|
||||
*/
|
||||
private function groupPresets(
|
||||
PresetBook $book,
|
||||
PresetScope $scope,
|
||||
string $module,
|
||||
?array $existing,
|
||||
array $input,
|
||||
string $field,
|
||||
array &$errors
|
||||
): array {
|
||||
$nested = is_array($existing['groupPresets'] ?? null) ? $existing['groupPresets'] : [];
|
||||
$wanted = $input['group_presets'] ?? null;
|
||||
if ($wanted === null) {
|
||||
return $nested;
|
||||
}
|
||||
if ($scope->kind !== PresetScope::MODULE || ! is_array($wanted)) {
|
||||
$errors[$field . '.group_presets'] = 'nur bei Modul-Presets, als Objekt Gruppe → Preset';
|
||||
|
||||
return $nested;
|
||||
}
|
||||
|
||||
$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);
|
||||
continue;
|
||||
}
|
||||
if ($preset === null) {
|
||||
unset($nested[$groupId]);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$found = is_string($preset)
|
||||
? $book->find(new PresetScope(PresetScope::GROUP, $groups[$groupId]), $preset)
|
||||
: null;
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$path] = $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
if ($found === null) {
|
||||
$errors[$path] = sprintf('unbekanntes Gruppen-Preset "%s" (%s)', (string) $preset, $groups[$groupId]);
|
||||
continue;
|
||||
}
|
||||
$nested[$groupId] = ['presetId' => [(string) $found['id']], 'groupName' => $groups[$groupId]];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, string> $errors
|
||||
*/
|
||||
private function nameOf(?array $existing, array $input, string $field, array &$errors): string
|
||||
{
|
||||
foreach (['rename', 'name'] as $key) {
|
||||
if (is_string($input[$key] ?? null) && trim($input[$key]) !== '') {
|
||||
return trim($input[$key]);
|
||||
}
|
||||
}
|
||||
if (is_string($existing['name'] ?? null)) {
|
||||
return $existing['name'];
|
||||
}
|
||||
$errors[$field . '.name'] = 'Name des neuen Presets fehlt';
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param array{all: array<string, mixed>, style: array<string, mixed>, render: array<string, mixed>} $split
|
||||
* @param array<string, mixed> $nested
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function item(
|
||||
PresetScope $scope,
|
||||
?array $existing,
|
||||
string $name,
|
||||
string $module,
|
||||
?string $groupId,
|
||||
array $attrs,
|
||||
array $split,
|
||||
array $nested
|
||||
): array {
|
||||
$now = ($this->now)();
|
||||
$item = ['type' => $scope->kind, 'id' => (string) ($existing['id'] ?? PresetBook::newId()), 'name' => $name];
|
||||
if ($groupId !== null) {
|
||||
$item += ['groupName' => $scope->name, 'groupId' => $groupId];
|
||||
}
|
||||
$item += [
|
||||
'moduleName' => $module,
|
||||
'version' => $this->version,
|
||||
'created' => (int) ($existing['created'] ?? $now),
|
||||
'updated' => $now,
|
||||
'attrs' => $attrs,
|
||||
];
|
||||
if ($split['style'] !== []) {
|
||||
$item['styleAttrs'] = $split['style'];
|
||||
}
|
||||
if ($split['render'] !== []) {
|
||||
$item['renderAttrs'] = $split['render'];
|
||||
}
|
||||
if ($nested !== []) {
|
||||
$item['groupPresets'] = $nested;
|
||||
}
|
||||
foreach (['priority', 'order'] as $key) {
|
||||
if (isset($existing[$key])) {
|
||||
$item[$key] = $existing[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private static function inGroup(string $path, string $groupId): bool
|
||||
{
|
||||
return $path === $groupId || str_starts_with($path, $groupId . '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of $attrs below the dotted path $groupId.
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function below(array $attrs, string $groupId): array
|
||||
{
|
||||
$value = AttrMap::get($attrs, $groupId);
|
||||
|
||||
return $value === null ? [] : AttrMap::set([], $groupId, $value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user