From 5ba52b98b3f0d23a43ba0e68b190704ae77713e4 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 16 Oct 2026 17:31:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20SnapshotStore=20=E2=80=93=20Snapshots?= =?UTF-8?q?=20des=20Designsystems=20je=20Bereich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- .../Divi/Design/SnapshotStore.php | 135 ++++++++++++++++++ .../Divi/Design/SnapshotTooLarge.php | 18 +++ src/Uninstaller.php | 9 +- .../Divi/Design/SnapshotStoreTest.php | 96 +++++++++++++ tests/Unit/UninstallTest.php | 2 + tests/Unit/UninstallerTest.php | 2 + 6 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/Integrations/Divi/Design/SnapshotStore.php create mode 100644 src/Integrations/Divi/Design/SnapshotTooLarge.php create mode 100644 tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php diff --git a/src/Integrations/Divi/Design/SnapshotStore.php b/src/Integrations/Divi/Design/SnapshotStore.php new file mode 100644 index 0000000..7fa208b --- /dev/null +++ b/src/Integrations/Divi/Design/SnapshotStore.php @@ -0,0 +1,135 @@ +, never autoloaded), + * newest first, at most LIMIT entries – the oldest falls out. An entry keeps the area's raw inventory as Divi stores + * it plus who made it with which tool and the change log entry. The only way back for colors, variables and presets: + * Divi 5 keeps no server-side history (formats §7.6). Not final so abilities can be tested with a mock. + */ +class SnapshotStore +{ + public const AREAS = ['colors', 'variables', 'presets', 'theme_options']; + + public const LIMIT = 20; + + /** Spec §5: a larger snapshot refuses the change. */ + public const MAX_BYTES = 2000000; + + private const PREFIX = 'lit_mcp_snapshots_'; + + public static function option(string $area): string + { + if (! in_array($area, self::AREAS, true)) { + throw new InvalidArgumentException(sprintf('Unknown design area "%s".', $area)); + } + + return self::PREFIX . $area; + } + + /** + * @param array $data The area's raw inventory. + * @throws SnapshotTooLarge + * @throws RuntimeException When the option could not be written. + */ + public function save(string $area, array $data, string $ability): string + { + $size = strlen((string) wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + if ($size > self::MAX_BYTES) { + throw new SnapshotTooLarge($size); + } + + $id = sprintf('%s-%s-%s', $area, gmdate('Ymd\THis\Z'), bin2hex(random_bytes(3))); + $entries = $this->entries($area); + array_unshift($entries, [ + 'id' => $id, + 'created' => gmdate('c'), + 'user_id' => get_current_user_id(), + 'ability' => $ability, + 'log_id' => null, + 'size' => $size, + 'data' => $data, + ]); + if (! update_option(self::option($area), array_slice($entries, 0, self::LIMIT), false)) { + throw new RuntimeException(sprintf('snapshot %s could not be stored', $area)); + } + + return $id; + } + + /** Notes the change log entry of the change the snapshot belongs to (the log entry is written after it). */ + public function attachLog(string $area, string $id, ?int $logId): void + { + if ($logId === null) { + return; + } + + $entries = $this->entries($area); + foreach ($entries as $index => $entry) { + if ($entry['id'] === $id) { + $entries[$index]['log_id'] = $logId; + update_option(self::option($area), $entries, false); + + return; + } + } + } + + /** @return list */ + public function list(string $area): array + { + return array_map(static function (array $entry): array { + unset($entry['data']); + + return $entry; + }, $this->entries($area)); + } + + /** @return array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int, + * data: array}|null */ + public function get(string $area, string $id): ?array + { + foreach ($this->entries($area) as $entry) { + if ($entry['id'] === $id) { + return $entry; + } + } + + return null; + } + + /** @return list}> */ + private function entries(string $area): array + { + $stored = get_option(self::option($area), []); + if (! is_array($stored)) { + return []; + } + + $entries = []; + foreach ($stored as $entry) { + if (is_array($entry) && is_string($entry['id'] ?? null) && is_array($entry['data'] ?? null)) { + $entries[] = [ + 'id' => $entry['id'], + 'created' => (string) ($entry['created'] ?? ''), + 'user_id' => (int) ($entry['user_id'] ?? 0), + 'ability' => (string) ($entry['ability'] ?? ''), + 'log_id' => isset($entry['log_id']) ? (int) $entry['log_id'] : null, + 'size' => (int) ($entry['size'] ?? 0), + 'data' => $entry['data'], + ]; + } + } + + return $entries; + } +} diff --git a/src/Integrations/Divi/Design/SnapshotTooLarge.php b/src/Integrations/Divi/Design/SnapshotTooLarge.php new file mode 100644 index 0000000..e49d50a --- /dev/null +++ b/src/Integrations/Divi/Design/SnapshotTooLarge.php @@ -0,0 +1,18 @@ + */ + private array $options = []; + + protected function setUp(): void + { + parent::setUp(); + $this->options = []; + Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default); + Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { + self::assertFalse($autoload, 'snapshots are never autoloaded'); + $this->options[$name] = $value; + + return true; + }); + Functions\when('get_current_user_id')->justReturn(7); + } + + public function testSavesListsAndReturnsTheNewestFirst(): void + { + $store = new SnapshotStore(); + $first = $store->save('presets', ['module' => ['a' => 1]], 'lit/divi-presets-set'); + $second = $store->save('presets', ['module' => ['a' => 2]], 'lit/divi-presets-set'); + $store->attachLog('presets', $second, 42); + + self::assertSame('lit_mcp_snapshots_presets', SnapshotStore::option('presets')); + self::assertMatchesRegularExpression('/^presets-\d{8}T\d{6}Z-[0-9a-f]{6}$/', $first); + $list = $store->list('presets'); + self::assertSame([$second, $first], array_column($list, 'id')); + self::assertSame(42, $list[0]['log_id']); + self::assertSame(7, $list[0]['user_id']); + self::assertArrayNotHasKey('data', $list[0]); + self::assertSame(['module' => ['a' => 1]], $store->get('presets', $first)['data']); + self::assertNull($store->get('presets', 'presets-unknown')); + self::assertSame([], $store->list('colors')); + } + + public function testKeepsAtMostTwentyPerArea(): void + { + $store = new SnapshotStore(); + $ids = []; + for ($i = 0; $i < 22; ++$i) { + $ids[] = $store->save('presets', ['n' => $i], 'lit/divi-presets-set'); + } + + $list = $store->list('presets'); + self::assertCount(SnapshotStore::LIMIT, $list); + self::assertSame($ids[21], $list[0]['id']); + self::assertNull($store->get('presets', $ids[0])); + self::assertNull($store->get('presets', $ids[1])); + } + + public function testRefusesSnapshotsAboveTheLimit(): void + { + $this->expectException(SnapshotTooLarge::class); + + (new SnapshotStore())->save('presets', ['x' => str_repeat('a', SnapshotStore::MAX_BYTES)], 'lit/divi-presets-set'); + } + + public function testUnknownAreaIsAProgrammingError(): void + { + $this->expectException(InvalidArgumentException::class); + + SnapshotStore::option('pages'); + } + + public function testAFailedOptionWriteThrows(): void + { + Functions\when('update_option')->justReturn(false); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('snapshot presets could not be stored'); + + (new SnapshotStore())->save('presets', ['a' => 1], 'lit/divi-presets-set'); + } + + public function testCorruptOptionReadsAsEmpty(): void + { + $this->options['lit_mcp_snapshots_presets'] = 'kaputt'; + + self::assertSame([], (new SnapshotStore())->list('presets')); + } +} diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index 87aba14..8a1774e 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -55,6 +55,8 @@ final class UninstallTest extends TestCase $own = [ Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', + 'lit_mcp_snapshots_theme_options', ]; self::assertSame(array_merge($own, $own), $deleted); self::assertNotContains('mcp_jwt_secret', $deleted); diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index edb9038..3fc8622 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -61,6 +61,8 @@ final class UninstallerTest extends TestCase [ Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', + 'lit_mcp_snapshots_theme_options', ], $deleted );