feat: SnapshotStore – Snapshots des Designsystems je Bereich
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
388b52ea76
commit
5ba52b98b3
@@ -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
@@ -7,9 +7,10 @@ namespace LIT\MCP;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check and
|
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options (incl. the Snapshots des
|
||||||
* Divi-release transients, the log-cleanup cron hook and the change log table – on every
|
* Designsystems, Etappe 4), the endpoint-check and Divi-release transients, the log-cleanup
|
||||||
* site of a network – and the Protokoll tab's per-user screen option.
|
* 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
|
* 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,
|
* is executed by WordPress core standalone, without loading the plugin's main file first,
|
||||||
@@ -33,6 +34,8 @@ final class Uninstaller
|
|||||||
private const OPTIONS = [
|
private const OPTIONS = [
|
||||||
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
|
'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_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 TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
|
||||||
private const CRON_HOOK = 'lit_mcp_log_cleanup';
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ final class UninstallTest extends TestCase
|
|||||||
$own = [
|
$own = [
|
||||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
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::assertSame(array_merge($own, $own), $deleted);
|
||||||
self::assertNotContains('mcp_jwt_secret', $deleted);
|
self::assertNotContains('mcp_jwt_secret', $deleted);
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ final class UninstallerTest extends TestCase
|
|||||||
[
|
[
|
||||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
||||||
|
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||||||
|
'lit_mcp_snapshots_theme_options',
|
||||||
],
|
],
|
||||||
$deleted
|
$deleted
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user