feat: SnapshotStore – Snapshots des Designsystems je Bereich

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:31:48 +02:00
co-authored by Claude Haiku 5.5
parent 388b52ea76
commit 5ba52b98b3
6 changed files with 259 additions and 3 deletions
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Snapshots of the design system (spec §5, G6): one option per area (lit_mcp_snapshots_<area>, 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<array-key, mixed> $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<array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int}> */
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<array-key, mixed>}|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<array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int,
* data: array<array-key, mixed>}> */
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;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use RuntimeException;
defined('ABSPATH') || exit;
/** A snapshot above SnapshotStore::MAX_BYTES (spec §5): the change is refused instead of writing without a backup. */
final class SnapshotTooLarge extends RuntimeException
{
public function __construct(public readonly int $size)
{
parent::__construct(sprintf('snapshot of %d bytes exceeds %d', $size, SnapshotStore::MAX_BYTES));
}
}
+6 -3
View File
@@ -7,9 +7,10 @@ namespace LIT\MCP;
defined('ABSPATH') || exit;
/**
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check and
* Divi-release transients, the log-cleanup cron hook and the change log table – on every
* site of a network – and the Protokoll tab's per-user screen option.
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options (incl. the Snapshots des
* Designsystems, Etappe 4), the endpoint-check and Divi-release transients, the log-cleanup
* cron hook and the change log table – on every site of a network – and the Protokoll tab's
* per-user screen option.
*
* Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php
* is executed by WordPress core standalone, without loading the plugin's main file first,
@@ -33,6 +34,8 @@ final class Uninstaller
private const OPTIONS = [
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
private const CRON_HOOK = 'lit_mcp_log_cleanup';
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class SnapshotStoreTest extends TestCase
{
/** @var array<string, mixed> */
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'));
}
}
+2
View File
@@ -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);
+2
View File
@@ -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
);