From d7a8b85f8ded070fb51a248fb9ef9638e618a42b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 14:10:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-variables-set=20legt=20globale?= =?UTF-8?q?=20Variablen=20an,=20=C3=A4ndert=20und=20archiviert=20sie=20(Pr?= =?UTF-8?q?obelauf,=20design=5Frev,=20Snapshot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/DiviVariablesSetAbility.php | 373 ++++++++++++++++++ .../Design/DiviVariablesSetAbilityTest.php | 319 +++++++++++++++ 2 files changed, 692 insertions(+) create mode 100644 src/Integrations/Divi/Design/DiviVariablesSetAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php diff --git a/src/Integrations/Divi/Design/DiviVariablesSetAbility.php b/src/Integrations/Divi/Design/DiviVariablesSetAbility.php new file mode 100644 index 0000000..158e3b8 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviVariablesSetAbility.php @@ -0,0 +1,373 @@ + '', '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 $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 $item + * @param array $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 $item + * @param array $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 $item + * @param array $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; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php new file mode 100644 index 0000000..c64ccd4 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php @@ -0,0 +1,319 @@ +>> What VariableAreaTest::adapter() stores. */ + private array $stored = []; + + /** @var list, 1: array}> */ + private array $saves = []; + + protected function setUp(): void + { + parent::setUp(); + // DesignLock (final review I-5): the lock is always free here. + Functions\when('add_option')->justReturn(true); + Functions\when('delete_option')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + $this->stored = VariableAreaTest::stored(); + $this->saves = []; + } + + /** @param array $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */ + private function ability( + ?GlobalUsages $usages = null, + ?SnapshotStore $snapshots = null, + array $presets = [], + ?GlobalVariablesAdapter $variables = null, + bool $manage = true + ): DiviVariablesSetAbility { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets); + $counter = 0; + + return new DiviVariablesSetAbility( + $writer, + $variables ?? VariableAreaTest::adapter($this->stored, $this->saves, $manage), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages ?? self::usages(self::unused()), + static function (int $length) use (&$counter): string { + return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT); + } + ); + } + + private static function usages(UsageReport $report): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return $usages; + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + private static function used(): UsageReport + { + return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, 1, [], 0, false); + } + + /** A SnapshotStore that expects one snapshot of the stored variables and the log ID 3 attached to it. */ + private static function snapshots(): SnapshotStore + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save') + ->with('variables', VariableBook::fromDivi(VariableAreaTest::stored())->data(), 'lit/divi-variables-set') + ->andReturn('variables-1'); + $snapshots->expects('attachLog')->with('variables', 'variables-1', 3); + + return $snapshots; + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + $properties = $ability->inputSchema()['properties']; + + self::assertSame('lit/divi-variables-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::destructive(false), $ability->annotations()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $ability->diviFeatures([])); + self::assertSame(50, DiviVariablesSetAbility::MAX_ITEMS); + self::assertSame(DiviVariablesSetAbility::MAX_ITEMS, $properties['items']['maxItems']); + self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'ruling V1: only archived'); + self::assertArrayHasKey('design_rev', $properties); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); + self::assertStringContainsString('lit/divi-design-restore', $ability->description()); + self::assertStringContainsString('lit/divi-design-detach', $ability->description()); + } + + public function testNeedsTheVariablesManagerAndEditThemeOptions(): void + { + $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; + + self::assertToolError($this->ability(manage: false)->execute($input), 'forbidden'); + self::assertToolError($this->ability(manage: false)->permission($input), 'forbidden'); + + Functions\when('current_user_can')->justReturn(false); + self::assertToolError($this->ability()->execute($input), 'forbidden'); + self::assertSame([], $this->saves); + } + + public function testItemsMustBeAListOfOneToFifty(): void + { + $tooMany = array_fill(0, 51, ['type' => 'numbers', 'label' => 'X', 'value' => '1px']); + foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) { + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + } + } + + public function testDryRunThenConfirmCreatesAVariable(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); // ruling V8: a new variable has no usages + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['added'], array_column($dry['diff'], 'action')); + self::assertSame('Abstand', $dry['diff'][0]['name']); + self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->rev(), $dry['design_rev']); + self::assertSame([], $dry['affected']); + self::assertSame([], $dry['warnings']); + self::assertStringContainsString(VariableDiff::summary($dry['diff']), $dry['message']); + self::assertSame([], $this->saves, 'the dry run saves nothing'); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame('variables-1', $result['snapshot_id']); + $created = array_diff_key($this->stored['numbers'], VariableAreaTest::stored()['numbers']); + self::assertCount(1, $created); + $id = (string) array_key_first($created); + self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/D', $id); + self::assertSame( + [$id, 'Abstand', '24px', '2', 'active', 'numbers'], + [$created[$id]['id'], $created[$id]['label'], $created[$id]['value'], $created[$id]['order'], + $created[$id]['status'], $created[$id]['variableType']] + ); + self::assertMatchesRegularExpression( + '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', + $created[$id]['lastUpdated'] + ); + self::assertSame(VariableAreaTest::stored(), $this->saves[0][1], 'Divi checks against the inventory read'); + self::assertSame(VariableBook::fromDivi($this->stored)->rev(), $result['design_rev']); + } + + public function testConfirmNeedsTheDesignRevOfTheDryRun(): void + { + $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']], 'confirm' => true]; + + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); + self::assertSame([], $this->saves); + } + + public function testChangingValueAndLabelListsTheUsages(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->twice() // dry run and confirm + ->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool + => $query->matches('') + && ! $query->matches(''))) + ->andReturn(self::used()); + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['id' => 'slogan', 'label' => 'Leitsatz', 'value' => 'Holz mit Seele']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['changed'], array_column($dry['diff'], 'action')); + self::assertSame(7, $dry['affected'][0]['post_id']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $slogan = $this->stored['strings']['gvid-slogan0001']; + self::assertSame(['Leitsatz', 'Holz mit Seele', '1', 'active'], [$slogan['label'], $slogan['value'], + $slogan['order'], $slogan['status']]); + } + + public function testTheTypeOfAVariableStays(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'Eckenradius', 'type' => 'strings', 'value' => 'x']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].type'], array_keys(self::errorFields($error))); + } + + /** Ruling V6: gradients are passed through, never changed by the tool. */ + public function testGradientsAreLeftToTheBuilder(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'Verlauf', 'status' => 'archived']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].id'], array_keys(self::errorFields($error))); + } + + /** Ruling V1: archiving clears the order; activating appends the variable at the end of its type. */ + public function testArchivingClearsTheOrderAndActivatingAppendsIt(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [ + ['id' => 'Eckenradius', 'status' => 'archived'], + ['id' => 'Alter Slogan', 'status' => 'active'], + ]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + $actions = array_column($dry['diff'], 'action'); + sort($actions); + self::assertSame(['activated', 'archived'], $actions); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $radius = $this->stored['numbers']['gvid-radius0001']; + $old = $this->stored['strings']['gvid-alt0000001']; + self::assertSame(['archived', ''], [$radius['status'], $radius['order']]); + self::assertSame(['active', '2'], [$old['status'], $old['order']]); + self::assertSame('8px', $radius['value'], 'archiving keeps the value'); + } + + public function testActiveVariablesNeedAValue(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'gvid-leer000001', 'status' => 'active']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].value'], array_keys(self::errorFields($error))); + } + + public function testAllProblemsOfAllItemsAreCollected(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'Fehlt', 'value' => '1px'], + ['type' => 'colors', 'label' => 'Farbe', 'value' => '#fff'], + ['type' => 'numbers', 'label' => 'eckenradius', 'value' => '1px'], + ['type' => 'numbers', 'label' => 'Neu', 'value' => '1px;}'], + ['type' => 'numbers', 'label' => 'Ohne Wert'], + ['id' => 'Slogan', 'status' => 'gelöscht'], + 'kein Objekt', + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].id', 'items[1].type', 'items[2].label', 'items[3].value', 'items[4].value', 'items[5].status', + 'items[6]'], + array_keys(self::errorFields($error)) + ); + self::assertSame([], $this->saves); + } + + /** Divi silently drops variables with an empty label or the label "0" (sanitize_global_variables_data). */ + public function testEmptyAndZeroLabelsAreRefused(): void + { + $error = $this->ability()->execute(['items' => [ + ['type' => 'numbers', 'label' => '', 'value' => '1px'], + ['type' => 'numbers', 'label' => '0', 'value' => '1px'], + ['id' => 'Eckenradius', 'label' => ' '], + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].label', 'items[1].label', 'items[2].label'], array_keys(self::errorFields($error))); + } + + public function testAFailedWriteIsRolledBack(): void + { + $calls = []; + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn(true); + $variables->allows('read')->andReturn(VariableAreaTest::stored()); + $variables->allows('save')->andReturnUsing(static function (array $data, array $expected) use (&$calls): void { + $calls[] = $data; + if (count($calls) === 1) { + throw new RuntimeException('global_variables_stale'); + } + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('variables-1'); + $ability = $this->ability(null, $snapshots, [], $variables); + $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']]]; + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + try { + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('variables-1', $e->details['snapshot_id']); + } + self::assertCount(2, $calls); + self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->data(), $calls[1], 'the snapshot again'); + } +}