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));
}
}