From a7a385d3b3636dd6998b52b5e8bc3d954fe2f2e5 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 14:00:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20VariableBook=20als=20reiner=20Variablen?= =?UTF-8?q?bestand=20mit=20Lese-/Speicherform,=20Archivierung=20statt=20L?= =?UTF-8?q?=C3=B6schen=20und=20Restore-Vereinigung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/Design/VariableBook.php | 260 ++++++++++++++++ .../Divi/Design/VariableBookTest.php | 285 ++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 src/Integrations/Divi/Design/VariableBook.php create mode 100644 tests/Unit/Integrations/Divi/Design/VariableBookTest.php diff --git a/src/Integrations/Divi/Design/VariableBook.php b/src/Integrations/Divi/Design/VariableBook.php new file mode 100644 index 0000000..4fc9106 --- /dev/null +++ b/src/Integrations/Divi/Design/VariableBook.php @@ -0,0 +1,260 @@ + id => {id, label, value, order, status, + * lastUpdated, variableType}. Takes the stored shape and the shape of GlobalData::get_global_variables() alike: + * groups and entries may be objects, "allowedActions", the customizer fonts (--et_global_*_font), the group "colors" + * and empty groups are dropped. The type "gradients" (Divi 5.15) is kept unchanged (ruling V6). Variables are never + * removed, only archived (ruling V1): restoredFrom() keeps every current variable. Immutable. Pure PHP. + */ +final class VariableBook +{ + /** Types in the order of Divi's stored data; VariableValue::TYPES plus the pass-through "gradients". */ + private const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts', 'gradients']; + + private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + + /** @param array>> $data */ + private function __construct(private array $data) + { + } + + /** @param array|object $raw Stored data or get_global_variables(). */ + public static function fromDivi(array|object $raw): self + { + $raw = self::plain($raw); + $data = []; + foreach (self::TYPES as $type) { + foreach (is_array($raw[$type] ?? null) ? $raw[$type] : [] as $id => $entry) { + // Customizer fonts have IDs like "--et_global_body_font"; Divi stores only "gvid-" variables. + if (is_array($entry) && str_starts_with((string) $id, 'gvid-')) { + $data[$type][(string) $id] = self::normalize($type, (string) $id, $entry); + } + } + } + + return new self($data); + } + + /** @return array>> */ + public function data(): array + { + return $this->data; + } + + public function rev(): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + $json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return Rev::of('variables' . "\0" . $json); + } + + /** @return array|null */ + public function entry(string $type, string $id): ?array + { + return $this->data[$type][$id] ?? null; + } + + public function typeOf(string $id): ?string + { + foreach ($this->data as $type => $entries) { + if (isset($entries[$id])) { + return $type; + } + } + + return null; + } + + /** + * The variable $needle names: an ID, else the one variable of that label (case-insensitive, trimmed) – + * optionally only within $type. + * + * @return array{type: string, id: string}|null + * @throws InvalidArgumentException When several variables share the label (ruling S13a). + */ + public function find(string $needle, ?string $type = null): ?array + { + $key = mb_strtolower(trim($needle)); + $hits = []; + foreach ($this->data as $kind => $entries) { + if ($type !== null && $kind !== $type) { + continue; + } + if (isset($entries[$needle])) { + return ['type' => $kind, 'id' => $needle]; + } + foreach ($entries as $id => $entry) { + if ($key !== '' && mb_strtolower(trim((string) $entry['label'])) === $key) { + $hits[] = ['type' => $kind, 'id' => (string) $id]; + } + } + } + if (count($hits) > 1) { + throw new InvalidArgumentException(sprintf( + 'Name der globalen Variable "%s" ist mehrdeutig; bitte die ID angeben: %s.', + trim($needle), + implode(', ', array_column($hits, 'id')) + )); + } + + return $hits[0] ?? null; + } + + /** + * @param array $entry Added or replaced; missing keys are filled, id and variableType are set. + * @throws InvalidArgumentException For a type Divi does not store. + */ + public function with(string $type, string $id, array $entry): self + { + if (! in_array($type, self::TYPES, true)) { + throw new InvalidArgumentException(sprintf('Unbekannter Variablentyp "%s".', $type)); + } + $data = $this->data; + $data[$type][$id] = self::normalize($type, $id, $entry); + + return new self(self::inTypeOrder($data)); + } + + /** The order for a variable appended to $type: the highest numeric order there plus one, as a string. */ + public function nextOrder(string $type): string + { + $highest = 0; + foreach ($this->data[$type] ?? [] as $entry) { + if (is_numeric($entry['order'])) { + $highest = max($highest, (int) $entry['order']); + } + } + + return (string) ($highest + 1); + } + + /** + * The inventory a restore writes (ruling V1b): every variable of $snapshot, and every variable that exists only + * here stays – archived, with order "" (Divi refuses a save that drops a stored ID). + */ + public function restoredFrom(self $snapshot): self + { + $data = $snapshot->data; + foreach ($this->data as $type => $entries) { + foreach ($entries as $id => $entry) { + if (! isset($snapshot->data[$type][$id])) { + $data[$type][$id] = array_merge($entry, ['status' => 'archived', 'order' => '']); + } + } + } + + return new self(self::inTypeOrder($data)); + } + + /** + * The variables restoredFrom() would archive: active ones that are not in $snapshot (already archived ones stay + * archived and need no warning). + * + * @return list + */ + public function onlyHere(self $snapshot): array + { + $rows = []; + foreach ($this->data as $type => $entries) { + foreach ($entries as $id => $entry) { + if (! isset($snapshot->data[$type][$id]) && $entry['status'] !== 'archived') { + $rows[] = ['type' => $type, 'id' => (string) $id, 'label' => (string) $entry['label']]; + } + } + } + + return $rows; + } + + /** A new variable ID like the Visual Builder's: "gvid-" and 10 characters of [0-9a-z] (formats §2). */ + public static function newId(?Closure $random = null): string + { + $random ??= static function (int $length): string { + $id = ''; + for ($i = 0; $i < $length; ++$i) { + $id .= self::ALPHABET[random_int(0, 35)]; + } + + return $id; + }; + + return 'gvid-' . $random(10); + } + + /** lastUpdated of a variable written now (same format as a color's). */ + public static function now(): string + { + return ColorBook::now(); + } + + /** + * @param array $entry + * @return array + */ + private static function normalize(string $type, string $id, array $entry): array + { + $text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null) + ? (string) $entry[$key] + : $default; + + return [ + 'id' => $id, + 'label' => $text('label', ''), + 'value' => $text('value', ''), + 'order' => $text('order', ''), + 'status' => $text('status', 'active'), + 'lastUpdated' => $text('lastUpdated', ''), + 'variableType' => $type, + ]; + } + + /** + * @param array>> $data + * @return array>> + */ + private static function inTypeOrder(array $data): array + { + $ordered = []; + foreach (self::TYPES as $type) { + if (isset($data[$type])) { + $ordered[$type] = $data[$type]; + } + } + + return $ordered; + } + + private static function sorted(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + $value = array_map([self::class, 'sorted'], $value); + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/VariableBookTest.php b/tests/Unit/Integrations/Divi/Design/VariableBookTest.php new file mode 100644 index 0000000..7fdb1d9 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/VariableBookTest.php @@ -0,0 +1,285 @@ + One stored variable (formats §2). */ + public static function variable( + string $type, + string $id, + string $label, + string $value, + string $order = '1', + string $status = 'active' + ): array { + return [ + 'id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status, + 'lastUpdated' => '2026-09-27T22:21:39.000Z', 'variableType' => $type, + ]; + } + + /** @return array>> The stored shape (et_divi_global_variables). */ + public static function stored(): array + { + return [ + 'numbers' => [ + 'gvid-f6yq6mayfy' => self::variable('numbers', 'gvid-f6yq6mayfy', 'Eckenradius', '8px', '1'), + 'gvid-5dec72fwsc' => self::variable('numbers', 'gvid-5dec72fwsc', 'Abstand Karte', '2rem', '2'), + 'gvid-old0000001' => self::variable('numbers', 'gvid-old0000001', 'Alt', '1px', '', 'archived'), + ], + 'strings' => [ + 'gvid-7uckqqhmrh' => self::variable('strings', 'gvid-7uckqqhmrh', 'Slogan', 'Handwerk', '1'), + 'gvid-dup0000001' => self::variable('strings', 'gvid-dup0000001', 'Eckenradius', 'Text', '2'), + ], + 'fonts' => [ + 'gvid-d4a5bsz2gt' => self::variable('fonts', 'gvid-d4a5bsz2gt', 'Akzentschrift', 'Merriweather'), + ], + 'gradients' => [ + 'gvid-grad000001' => self::variable( + 'gradients', + 'gvid-grad000001', + 'Verlauf', + 'linear-gradient(red, blue)' + ), + ], + ]; + } + + /** @return array The shape of GlobalData::get_global_variables() (formats §2, verified in DDEV). */ + public static function fromGetter(): array + { + $actions = ['editLabel' => true, 'editValue' => true, 'reorder' => true, 'remove' => true]; + $stored = self::stored(); + $withActions = static fn (array $group): array => array_map( + static fn (array $entry): array => $entry + ['allowedActions' => $actions], + $group + ); + + return [ + 'numbers' => (object) $withActions($stored['numbers']), + 'strings' => (object) $withActions($stored['strings']), + 'images' => new stdClass(), + 'links' => new stdClass(), + 'colors' => (object) ['gcid-x1' => ['id' => 'gcid-x1', 'label' => 'Farbe', 'value' => '#fff']], + 'fonts' => (object) ([ + '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', + 'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions], + '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', + 'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions], + ] + $withActions($stored['fonts'])), + 'gradients' => (object) $withActions($stored['gradients']), + ]; + } + + public function testStoredShapeIsKeptAsItIs(): void + { + self::assertSame(self::stored(), VariableBook::fromDivi(self::stored())->data()); + } + + public function testGetterShapeEqualsTheStoredShape(): void + { + $fromGetter = VariableBook::fromDivi(self::fromGetter()); + $fromStored = VariableBook::fromDivi(self::stored()); + + self::assertSame($fromStored->data(), $fromGetter->data()); + self::assertSame($fromStored->rev(), $fromGetter->rev()); + self::assertNull($fromGetter->typeOf('--et_global_body_font')); + self::assertNull($fromGetter->typeOf('gcid-x1')); + self::assertArrayNotHasKey('colors', $fromGetter->data()); + self::assertArrayNotHasKey('images', $fromGetter->data(), 'empty groups are dropped'); + self::assertArrayNotHasKey('allowedActions', $fromGetter->entry('numbers', 'gvid-f6yq6mayfy') ?? []); + } + + public function testGradientsPassThroughUnchanged(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame('linear-gradient(red, blue)', $book->entry('gradients', 'gvid-grad000001')['value']); + self::assertSame('gradients', $book->typeOf('gvid-grad000001')); + self::assertSame( + 'gradients', + $book->with('gradients', 'gvid-grad000001', ['label' => 'Verlauf 2', 'value' => 'x']) + ->entry('gradients', 'gvid-grad000001')['variableType'] + ); + } + + public function testMissingKeysAreFilledAndIdAndTypeComeFromTheSlot(): void + { + $book = VariableBook::fromDivi([ + 'numbers' => ['gvid-x1' => ['id' => 'other', 'label' => 'A', 'value' => 8, 'order' => null, + 'variableType' => 'strings', 'extra' => 'dropped']], + 'strings' => ['kein-gvid' => ['label' => 'B', 'value' => 'x']], + ]); + + self::assertSame([ + 'id' => 'gvid-x1', 'label' => 'A', 'value' => '8', 'order' => '', 'status' => 'active', + 'lastUpdated' => '', 'variableType' => 'numbers', + ], $book->entry('numbers', 'gvid-x1')); + self::assertArrayNotHasKey('strings', $book->data()); + } + + public function testRevIsStableAndChangesWithTheContent(): void + { + $book = VariableBook::fromDivi(self::stored()); + $reversed = VariableBook::fromDivi(array_map( + static fn (array $group): array => array_reverse($group, true), + array_reverse(self::stored(), true) + )); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); + self::assertSame($book->rev(), $reversed->rev()); + self::assertNotSame( + $book->rev(), + $book->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'X', 'value' => '9px'])->rev() + ); + } + + public function testRealDiviFixtureAndItsGetterShapeAreTheSame(): void + { + $json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-variables.json'); + $fixture = json_decode($json, true); + $stored = VariableBook::fromDivi($fixture['stored']); + $read = VariableBook::fromDivi($fixture['get_global_variables']); + + self::assertSame($stored->rev(), $read->rev()); + self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], array_keys($read->data())); + self::assertSame('8px', $read->entry('numbers', 'gvid-f6yq6mayfy')['value']); + self::assertNull($read->typeOf('--et_global_heading_font')); + } + + public function testFindByIdAndLabel(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('gvid-f6yq6mayfy')); + self::assertSame(['type' => 'strings', 'id' => 'gvid-7uckqqhmrh'], $book->find(' SLOGAN ')); + self::assertSame(['type' => 'fonts', 'id' => 'gvid-d4a5bsz2gt'], $book->find('Akzentschrift', 'fonts')); + self::assertNull($book->find('Akzentschrift', 'numbers')); + self::assertNull($book->find('gvid-d4a5bsz2gt', 'numbers')); + self::assertNull($book->find('Fehlt')); + self::assertNull($book->find('')); + } + + public function testAmbiguousLabelListsTheIdsAndAnArgumentTypeResolvesIt(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('Eckenradius', 'numbers')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Name der globalen Variable "Eckenradius" ist mehrdeutig; bitte die ID angeben: ' + . 'gvid-f6yq6mayfy, gvid-dup0000001.' + ); + $book->find('Eckenradius'); + } + + public function testWithIsImmutableAndKeepsTheTypeOrder(): void + { + $book = VariableBook::fromDivi(self::stored()); + $added = $book->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']); + + self::assertCount(3, $book->data()['numbers']); + self::assertSame(['numbers', 'strings', 'images', 'fonts', 'gradients'], array_keys($added->data())); + self::assertSame('images', $added->entry('images', 'gvid-img0000001')['variableType']); + self::assertSame('active', $added->entry('images', 'gvid-img0000001')['status']); + self::assertNull($book->entry('images', 'gvid-img0000001')); + } + + public function testWithRejectsTypesDiviDoesNotStore(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unbekannter Variablentyp "colors".'); + + VariableBook::fromDivi([])->with('colors', 'gvid-x1', ['label' => 'A', 'value' => '#fff']); + } + + public function testNextOrderIsTheHighestOrderPlusOne(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame('3', $book->nextOrder('numbers'), 'the archived entry with order "" does not count'); + self::assertSame('2', $book->nextOrder('fonts')); + self::assertSame('1', $book->nextOrder('images')); + self::assertSame('1', $book->nextOrder('colors')); + self::assertSame('11', $book->with('links', 'gvid-l1', ['label' => 'L', 'value' => '/', 'order' => '10']) + ->nextOrder('links')); + } + + public function testRestoredFromKeepsCurrentOnlyVariablesArchived(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px', 'order' => '3']) + ->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']) + ->with('strings', 'gvid-7uckqqhmrh', ['label' => 'Slogan neu', 'value' => 'Geändert', 'order' => '1']); + $snapshot = VariableBook::fromDivi(self::stored()); + + $restored = $current->restoredFrom($snapshot); + + self::assertSame('Slogan', $restored->entry('strings', 'gvid-7uckqqhmrh')['label'], 'snapshot wins'); + self::assertSame( + ['label' => 'Neu', 'value' => '4px', 'order' => '', 'status' => 'archived'], + array_intersect_key( + $restored->entry('numbers', 'gvid-new0000001') ?? [], + array_flip(['status', 'order', 'label', 'value']) + ) + ); + self::assertSame('archived', $restored->entry('images', 'gvid-img0000001')['status']); + self::assertSame('', $restored->entry('images', 'gvid-img0000001')['order']); + self::assertSame('archived', $restored->entry('numbers', 'gvid-old0000001')['status']); + // Every ID of the current inventory survives, as Divi demands. + foreach ($current->data() as $type => $entries) { + foreach (array_keys($entries) as $id) { + self::assertNotNull($restored->entry($type, (string) $id), $id); + } + } + self::assertSame($snapshot->rev(), $snapshot->restoredFrom($snapshot)->rev()); + } + + public function testRestoredArchivedEntryLosesItsOrder(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('fonts', 'gvid-gone000001', [ + 'label' => 'Weg', 'value' => 'Lato', 'order' => '7', 'status' => 'archived', + ]); + + $restored = $current->restoredFrom(VariableBook::fromDivi(self::stored())); + + self::assertSame(['archived', ''], [ + $restored->entry('fonts', 'gvid-gone000001')['status'], + $restored->entry('fonts', 'gvid-gone000001')['order'], + ]); + } + + public function testOnlyHereListsTheActiveVariablesARestoreWouldArchive(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px']) + ->with('fonts', 'gvid-gone000001', ['label' => 'Weg', 'value' => 'Lato', 'status' => 'archived']); + + self::assertSame( + [['type' => 'numbers', 'id' => 'gvid-new0000001', 'label' => 'Neu']], + $current->onlyHere(VariableBook::fromDivi(self::stored())) + ); + self::assertSame([], $current->onlyHere($current)); + } + + public function testNewIdHasTheBuilderFormat(): void + { + self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/', VariableBook::newId()); + self::assertNotSame(VariableBook::newId(), VariableBook::newId()); + self::assertSame( + 'gvid-zzzzzzzzzz', + VariableBook::newId(static fn (int $length): string => str_repeat('z', $length)) + ); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', VariableBook::now()); + } +}