feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:47:16 +02:00
co-authored by Claude Sonnet 5.5
parent 341782a34f
commit b3c3e69a53
8 changed files with 682 additions and 0 deletions
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1").
*/
final class DesignRights
{
public static function presets(DesignSystemWriter $writer): ?WP_Error
{
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden(
'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).'
);
}
if (! $writer->canUseVisualBuilder()) {
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.',
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
return null;
}
}
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-presets-set (spec §6.3, G7): creates and changes module and option group presets – from the short and full
* form, from a module of a page (from_node), with nested group presets and as the default preset – through the
* change pipeline (dry run, design_rev, snapshot, Divi's sync, change log). New names must be free within the
* module/group (A5). The dry run lists the posts that use a changed preset or reach a new default.
*/
final class DiviPresetsSetAbility implements Ability
{
public const MAX_ITEMS = 20;
private const NAME_MAX = 100;
private const NAME_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser '
. 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.';
/** @param Closure(): PresetComposer $composer */
public function __construct(
private DesignSystemWriter $writer,
private DesignChange $change,
private GlobalUsages $usages,
private DiviPageLoader $loader,
private Closure $composer
) {
}
public function name(): string
{
return 'lit/divi-presets-set';
}
public function label(): string
{
return 'Divi-Presets anlegen und ändern';
}
public function description(): string
{
return 'Legt Modul-Presets (module) und Gruppen-Presets (group) an oder ändert sie (preset = Name oder ID). '
. 'Einstellungen in attrs (Vollform wie bei lit-divi-update, mit Platzhaltern {{color:…}}/{{var:…}}) oder '
. 'aus einem Modul einer Seite (from_node: post_id + Pfad-ID); nur Gestaltung, keine Texte oder Links. '
. 'group_presets verschachtelt Gruppen-Presets, make_default macht das Preset zum Standard. Wirkt auf alle '
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, design_rev), nach Rückfrage beim '
. 'Nutzer confirm: true mit design_rev. Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => self::MAX_ITEMS,
'items' => [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string'],
'group' => ['type' => 'string'],
'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'],
'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'],
'rename' => ['type' => 'string'],
'set' => ['type' => 'object'],
'attrs' => ['type' => 'object'],
'from_node' => [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']],
'required' => ['post_id', 'id'],
],
'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'],
'group_id' => ['type' => 'string'],
'group_presets' => ['type' => 'object'],
'make_default' => ['type' => 'boolean'],
],
],
],
] + DesignChange::inputProperties(),
'required' => ['items'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return DesignChange::outputSchema();
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::presets($this->writer) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::presets($this->writer);
if ($refusal !== null) {
return $refusal;
}
$items = $input['items'] ?? null;
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
return Errors::invalidInput(
'items fehlt oder ist zu lang; nichts wurde gespeichert.',
['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)]
);
}
return $this->change->run(
$this->name(),
new PresetArea($this->writer),
$input,
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
);
}
/** @param list<mixed> $items */
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
{
$composer = ($this->composer)();
$book = $before;
$errors = [];
$invalid = [];
$targets = [];
$warnings = [];
foreach ($items as $index => $item) {
$field = sprintf('items[%d]', $index);
if (! is_array($item)) {
$errors[$field] = 'Objekt erwartet';
continue;
}
$prepared = $this->prepare($book, $item, $field, $errors);
if ($prepared === null) {
continue;
}
[$scope, $existing, $source] = $prepared;
$result = $composer->compose($book, $scope, $existing, $item, $field, $source);
$invalid = array_merge($invalid, $result['invalid_paths']);
if ($result['errors'] !== [] || $result['invalid_paths'] !== []) {
$errors += $result['errors'];
continue;
}
$id = (string) $result['item']['id'];
$book = $book->with($scope, $result['item']);
if (($item['make_default'] ?? null) === true) {
$book = $book->withDefault($scope, $id);
if ($scope->kind === PresetScope::GROUP) {
$warnings[] = self::GROUP_DEFAULT_WARNING;
}
} elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) {
$book = $book->withDefault($scope, '');
}
if ($existing !== null || $book->defaultId($scope) === $id) {
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id
|| $book->defaultId($scope) === $id];
}
}
if ($invalid !== []) {
return Errors::invalidAttrPath($errors);
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors);
}
$diff = PresetDiff::between($before, $book);
return new DesignPlan(
$book->data(),
$diff,
PresetDiff::summary($diff),
$targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)),
array_values(array_unique($warnings))
);
}
/**
* Scope, preset to change and source node of one item; null after noting the problem in $errors.
*
* @param array<array-key, mixed> $item Names are replaced by their cleaned form.
* @param array<string, string> $errors
* @return array{PresetScope, array<string, mixed>|null, Block|null}|null
*/
private function prepare(PresetBook $book, array &$item, string $field, array &$errors): ?array
{
try {
$scope = PresetScope::fromInput($item, $field);
} catch (InvalidArgumentException $e) {
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
$errors[$path] = $problem;
return null;
}
$existing = null;
try {
if (is_string($item['preset'] ?? null)) {
$existing = $book->find($scope, $item['preset']);
if ($existing === null) {
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']);
return null;
}
}
} catch (InvalidArgumentException $e) {
$errors[$field . '.preset'] = $e->getMessage();
return null;
}
foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) {
if (! $applies || ! is_string($item[$key] ?? null)) {
continue;
}
$name = $this->cleanName($item[$key]);
if ($name === null) {
$errors[$field . '.' . $key] = self::NAME_RULE;
return null;
}
$item[$key] = $name;
$taken = array_diff($book->idsNamed($scope, $name), [(string) ($existing['id'] ?? '')]);
if ($taken !== []) {
$errors[$field . '.' . $key] = sprintf(
'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen',
implode(', ', $taken)
);
return null;
}
}
$source = $this->source($scope, $item, $field, $errors);
if ($source === false) {
return null;
}
return [$scope, $existing, $source];
}
/** Plain text of 1 to 100 characters, or null (ruling: preset names). */
private function cleanName(string $name): ?string
{
$clean = sanitize_text_field($name);
$length = function_exists('mb_strlen') ? mb_strlen($clean) : strlen($clean);
return $clean === '' || $length > self::NAME_MAX || strlen($name) > self::NAME_MAX * 4 ? null : $clean;
}
/**
* @param array<array-key, mixed> $item
* @param array<string, string> $errors
*/
private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null
{
$from = $item['from_node'] ?? null;
if ($from === null) {
return null;
}
if (! is_array($from) || ! is_string($from['id'] ?? null)) {
$errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet';
return false;
}
$page = $this->loader->load($from['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
$errors[$field . '.from_node.post_id'] = Errors::clientMessage($page);
return false;
}
$block = $page->index->find($from['id']);
if ($block === null) {
$errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']);
return false;
}
if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) {
$errors[$field . '.from_node.id'] = sprintf(
'%s ist %s, nicht %s',
$from['id'],
$block->name(),
$scope->name
);
return false;
}
return $block;
}
}
@@ -39,6 +39,30 @@ final class PresetDiff
return $rows;
}
/**
* One German sentence for the dry run and the change log (spec §4 step 6).
*
* @param list<array<string, mixed>> $diff
*/
public static function summary(array $diff): string
{
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
$parts = array_filter([
isset($count['added']) ? $count['added'] . ' angelegt' : null,
isset($count['changed']) ? $count['changed'] . ' geändert' : null,
isset($count['removed']) ? $count['removed'] . ' gelöscht' : null,
]);
$defaults = array_filter([
isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null,
isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null,
]);
if ($defaults !== []) {
$parts[] = 'Standard ' . implode(', ', $defaults);
}
return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
}
/**
* @param array<string, array<string, mixed>> $was
* @param array<string, array<string, mixed>> $is
+30
View File
@@ -10,7 +10,11 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
@@ -155,6 +159,13 @@ final class DiviIntegration implements Integration
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
new DiviPresetsSetAbility(
$this->designWriter,
$this->designChange(),
$this->globalUsages(),
$loader,
$this->presetComposer(...)
),
];
}
@@ -217,6 +228,25 @@ final class DiviIntegration implements Integration
);
}
private function designChange(): DesignChange
{
return new DesignChange(new SnapshotStore(), $this->log);
}
private function presetComposer(): PresetComposer
{
$composition = new DiviComposition($this->modules, $this->design);
return new PresetComposer(
$composition->composer(),
$composition->storedGuard(),
$this->designWriter->presetAttrs(...),
$this->designWriter->groupsOf(...),
$this->modules->builderVersion(),
static fn (): int => (int) floor(microtime(true) * 1000)
);
}
private function loader(): DiviPageLoader
{
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);