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:
co-authored by
Claude Sonnet 5.5
parent
341782a34f
commit
b3c3e69a53
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user