feat: lit/divi-variables-set legt globale Variablen an, ändert und archiviert sie (Probelauf, design_rev, Snapshot)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:10:21 +02:00
co-authored by Claude Sonnet 5.5
parent f1f70114f4
commit d7a8b85f8d
2 changed files with 692 additions and 0 deletions
@@ -0,0 +1,373 @@
<?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\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-variables-set (spec §7.2 with rulings V1, V6–V8): creates and changes global variables through the change
* pipeline (VariableArea). An item names an existing variable in id (ID or name) or creates one from type, label and
* value. Variables are never deleted – Divi refuses an inventory that lacks a stored ID (V1) –, only archived (order
* "") and activated again (appended at the end of their type); the type of a variable stays, gradients are left to the
* builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that
* reference a changed, archived or activated variable (ReferenceUsageQuery).
*/
final class DiviVariablesSetAbility implements Ability, RequiresDiviFeatures
{
public const MAX_ITEMS = 50;
private const LABEL_MAX = 100;
private const LABEL_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const STATUSES = ['active', 'archived'];
/** A variable as Divi stores it (formats §2); new variables start from it. */
private const NEW_ENTRY = ['id' => '', 'label' => '', 'value' => '', 'order' => '', 'status' => 'active',
'lastUpdated' => '', 'variableType' => ''];
/** @param (Closure(int): string)|null $random Random characters for new IDs (VariableBook::newId(); tests). */
public function __construct(
private DesignSystemWriter $writer,
private GlobalVariablesAdapter $variables,
private DesignChange $change,
private GlobalUsages $usages,
private ?Closure $random = null
) {
}
public function name(): string
{
return 'lit/divi-variables-set';
}
public function label(): string
{
return 'Globale Divi-Variablen anlegen und ändern';
}
public function description(): string
{
return 'Legt globale Variablen an oder ändert sie. items: id nennt eine vorhandene Variable (ID oder Name); '
. 'ohne id entsteht eine neue aus type (numbers, strings, images, links, fonts), label und value. label '
. 'benennt um, status archived archiviert (Variablen werden nie gelöscht), active holt sie zurück; der Typ '
. 'bleibt. Verweise vor dem Archivieren mit lit/divi-design-detach durch den Wert ersetzen. Wirkt auf alle '
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, Warnungen, 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' => [
'id' => ['type' => 'string', 'description' => 'Vorhandene Variable: ID oder Name.'],
'type' => [
'type' => 'string',
'description' => 'Neue Variable: numbers, strings, images, links oder fonts.',
],
'label' => [
'type' => 'string',
'description' => 'Name einer neuen Variable oder umbenennen.',
],
'value' => ['type' => 'string'],
'status' => ['type' => 'string', 'description' => 'active oder archived.'],
],
],
],
] + 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;
}
/** Reads and writes the global variables; the warnings read the presets (DesignRead). */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::VariableWrite];
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::variables($this->writer, $this->variables) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::variables($this->writer, $this->variables);
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 VariableArea($this->variables),
$input,
fn (array $raw): DesignPlan|WP_Error => $this->plan(VariableBook::fromDivi($raw), $items)
);
}
/** @param list<mixed> $items */
private function plan(VariableBook $before, array $items): DesignPlan|WP_Error
{
$book = $before;
$errors = [];
foreach ($items as $index => $item) {
$field = sprintf('items[%d]', $index);
if (! is_array($item)) {
$errors[$field] = 'Objekt erwartet';
continue;
}
$book = $this->apply($book, $item, $field, $errors);
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Variablen-Angaben; nichts wurde gespeichert.', $errors);
}
$diff = VariableDiff::between($before, $book);
$touched = [];
foreach ($diff as $row) {
if ($row['action'] !== 'added') {
$touched[] = (string) $row['id'];
}
}
$touched = array_values(array_unique($touched));
return new DesignPlan(
$book->data(),
$diff,
VariableDiff::summary($diff),
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
);
}
/**
* $book with the variable of $item created or changed; problems go to $errors and leave $book as it was.
*
* @param array<array-key, mixed> $item
* @param array<string, string> $errors
*/
private function apply(VariableBook $book, array $item, string $field, array &$errors): VariableBook
{
$known = count($errors);
$named = array_key_exists('id', $item);
$found = $named ? self::existing($book, $item, $field, $errors) : null;
$type = $found['type'] ?? ($named ? null : self::newType($item, $field, $errors));
if ($type === null || count($errors) > $known) {
return $book;
}
$id = $found['id'] ?? null;
$before = $id === null ? null : $book->entry($type, $id);
$entry = $before ?? array_merge(self::NEW_ENTRY, ['variableType' => $type]);
foreach (['label', 'value', 'status'] as $key) {
if (! array_key_exists($key, $item)) {
if ($before === null && $key !== 'status') {
$errors[$field . '.' . $key] = 'Pflicht für eine neue Variable';
}
continue;
}
try {
$entry[$key] = match ($key) {
'label' => self::cleanLabel($book, $item['label'], $id),
'value' => self::value($type, $item['value']),
default => self::status($item['status']),
};
} catch (InvalidArgumentException $e) {
$errors[$field . '.' . $key] = $e->getMessage();
}
}
if (count($errors) > $known) {
return $book;
}
if ($entry['status'] === 'active' && ! self::hasValue($entry['value'] ?? null)) {
// Divi refuses it too (global_variable_active_value_empty, ruling V6).
$errors[$field . '.value'] = 'aktive Variablen brauchen einen Wert';
return $book;
}
$entry['order'] = match (true) {
$entry['status'] !== 'active' => '',
($before['status'] ?? null) !== 'active' => $book->nextOrder($type),
default => $entry['order'],
};
if ($entry === $before) {
return $book;
}
$id ??= $this->newId($book);
$entry['id'] = $id;
$entry['lastUpdated'] = VariableBook::now();
return $book->with($type, $id, $entry);
}
/**
* The variable $item['id'] names (within $item['type'] first, when given), or null after noting the problem.
*
* @param array<array-key, mixed> $item
* @param array<string, string> $errors
* @return array{type: string, id: string}|null
*/
private static function existing(VariableBook $book, array $item, string $field, array &$errors): ?array
{
$needle = $item['id'];
if (! is_string($needle) || trim($needle) === '') {
$errors[$field . '.id'] = 'ID oder Name der Variablen erwartet';
return null;
}
$type = is_string($item['type'] ?? null) ? $item['type'] : null;
try {
$found = ($type === null ? null : $book->find($needle, $type)) ?? $book->find($needle);
} catch (InvalidArgumentException $e) {
$errors[$field . '.id'] = $e->getMessage();
return null;
}
if ($found === null) {
$errors[$field . '.id'] = sprintf('unbekannte Variable „%s“', $needle);
return null;
}
if (! in_array($found['type'], VariableValue::TYPES, true)) {
$errors[$field . '.id'] = sprintf('Variablen vom Typ %s nur im Divi-Builder ändern', $found['type']);
return null;
}
if ($type !== null && $type !== $found['type']) {
$errors[$field . '.type'] = sprintf('Der Typ einer Variablen bleibt (ist %s)', $found['type']);
return null;
}
return $found;
}
/**
* @param array<array-key, mixed> $item
* @param array<string, string> $errors
*/
private static function newType(array $item, string $field, array &$errors): ?string
{
$type = $item['type'] ?? null;
if (is_string($type) && in_array($type, VariableValue::TYPES, true)) {
return $type;
}
$errors[$field . '.type'] = sprintf(
'Pflicht für eine neue Variable, einer von: %s',
implode(', ', VariableValue::TYPES)
);
return null;
}
/**
* A label no other variable has as name or ID (it would make {{var:…}} ambiguous).
*
* @throws InvalidArgumentException
*/
private static function cleanLabel(VariableBook $book, mixed $label, ?string $id): string
{
$clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : '';
// Divi drops variables whose label is empty or "0" (falsy) without a message.
if ($clean === '' || $clean === '0' || mb_strlen($clean) > self::LABEL_MAX) {
throw new InvalidArgumentException(self::LABEL_RULE);
}
try {
$taken = $book->find($clean)['id'] ?? null;
} catch (InvalidArgumentException) {
$taken = ''; // several variables share the name already
}
if ($taken !== null && $taken !== $id) {
throw new InvalidArgumentException(sprintf(
'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen',
$taken === '' ? 'mehrfach' : $taken
));
}
return $clean;
}
/** @throws InvalidArgumentException */
private static function value(string $type, mixed $value): string
{
if (is_int($value)) {
$value = (string) $value; // an attachment ID for images
}
if (! is_string($value)) {
throw new InvalidArgumentException('Wert als Text erwartet');
}
$problem = VariableValue::check($type, $value);
if ($problem !== null) {
throw new InvalidArgumentException($problem);
}
return $value;
}
/** @throws InvalidArgumentException */
private static function status(mixed $status): string
{
if (! is_string($status) || ! in_array($status, self::STATUSES, true)) {
throw new InvalidArgumentException('active oder archived');
}
return $status;
}
private static function hasValue(mixed $value): bool
{
return is_scalar($value) && (string) $value !== '';
}
private function newId(VariableBook $book): string
{
do {
$id = VariableBook::newId($this->random);
} while ($book->typeOf($id) !== null);
return $id;
}
}