feat: lit/divi-design-snapshots und lit/divi-design-restore (Bereich presets)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
2e8f723b01
commit
327f8d2d35
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/divi-design-restore (spec §5, G6): writes a snapshot back through the change pipeline – dry run with the diff
|
||||
* to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can be
|
||||
* undone as well. Etappe 4a restores presets; the other areas follow with their tools (4c, 4d).
|
||||
*/
|
||||
final class DiviDesignRestoreAbility implements Ability
|
||||
{
|
||||
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
|
||||
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private SnapshotStore $snapshots
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-design-restore';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Designsystem wiederherstellen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Stellt einen Snapshot des Designsystems wieder her (snapshot_id aus lit/divi-design-snapshots oder '
|
||||
. 'einer Änderungsantwort). Zuerst Probelauf (Unterschied zum aktuellen Stand, design_rev), nach Rückfrage '
|
||||
. 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['snapshot_id' => ['type' => 'string']] + DesignChange::inputProperties(),
|
||||
'required' => ['snapshot_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return DesignChange::outputSchema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(false);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return DesignRights::presets($this->writer) ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = DesignRights::presets($this->writer);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
$id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : '';
|
||||
if (preg_match(self::ID, $id, $match) !== 1) {
|
||||
return Errors::invalidInput(
|
||||
'Ungültige Snapshot-ID.',
|
||||
['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3']
|
||||
);
|
||||
}
|
||||
if ($match[1] !== 'presets') {
|
||||
return Errors::unsupported(
|
||||
'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.',
|
||||
'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.'
|
||||
);
|
||||
}
|
||||
$snapshot = $this->snapshots->get('presets', $id);
|
||||
if ($snapshot === null) {
|
||||
return Errors::notFound(
|
||||
sprintf('Snapshot %s gibt es nicht (mehr).', $id),
|
||||
'Vorhandene Snapshots mit lit/divi-design-snapshots auflisten; je Bereich bleiben die letzten 20.'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->change->run(
|
||||
$this->name(),
|
||||
new PresetArea($this->writer),
|
||||
$input,
|
||||
static function (array $raw) use ($snapshot, $id): DesignPlan {
|
||||
$before = PresetBook::fromDivi($raw);
|
||||
$after = PresetBook::fromDivi($snapshot['data']);
|
||||
$diff = PresetDiff::between($before, $after);
|
||||
|
||||
return new DesignPlan(
|
||||
$after->data(),
|
||||
$diff,
|
||||
sprintf('Snapshot %s wiederherstellen – %s', $id, PresetDiff::summary($diff)),
|
||||
null,
|
||||
['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul '
|
||||
. 'fällt dann auf das Standard-Preset zurück.'],
|
||||
true
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** lit/divi-design-snapshots (spec §5): the snapshots of one design area, newest first, without their content. */
|
||||
final class DiviDesignSnapshotsAbility implements Ability
|
||||
{
|
||||
public function __construct(private SnapshotStore $snapshots)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-design-snapshots';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Snapshots des Designsystems';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet die Snapshots eines Bereichs des Designsystems (area: presets, colors, variables, '
|
||||
. 'theme_options; Standard presets), neueste zuerst, mit Zeitpunkt, Tool und Protokolleintrag. '
|
||||
. 'Wiederherstellen mit lit/divi-design-restore.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'area' => ['type' => 'string', 'enum' => SnapshotStore::AREAS],
|
||||
] + Cursor::inputProperties(),
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'area' => ['type' => 'string'],
|
||||
'snapshots' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'next_cursor' => ['type' => ['string', 'null']],
|
||||
],
|
||||
'required' => ['area', 'snapshots', 'next_cursor'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::read();
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Snapshots ansehen erfordert das Recht, das Design anzupassen (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$area = $input['area'] ?? 'presets';
|
||||
if (! in_array($area, SnapshotStore::AREAS, true)) {
|
||||
return Errors::invalidInput('Unbekannter Bereich.', ['area' => implode(', ', SnapshotStore::AREAS)]);
|
||||
}
|
||||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||||
if ($offset === null) {
|
||||
return Cursor::invalid();
|
||||
}
|
||||
$limit = Cursor::limit($input['limit'] ?? null);
|
||||
$page = Cursor::page(array_slice($this->snapshots->list($area), $offset, $limit + 1), $limit, $offset);
|
||||
|
||||
return ['area' => $area, 'snapshots' => $page['items'], 'next_cursor' => $page['next_cursor']];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||||
@@ -168,6 +170,8 @@ final class DiviIntegration implements Integration
|
||||
$this->presetComposer(...)
|
||||
),
|
||||
new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),
|
||||
new DiviDesignSnapshotsAbility(new SnapshotStore()),
|
||||
new DiviDesignRestoreAbility($this->designWriter, $this->designChange(), new SnapshotStore()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviDesignRestoreAbilityTest extends TestCase
|
||||
{
|
||||
public function testRestoresWithDryRunAndASnapshotOfTheCurrentState(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$older = PresetBookTest::stored();
|
||||
unset($older['module']['divi/button']['items']['bbbbbbbbbb']);
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
$writer->expects('savePresets')->with(Mockery::type('array'), true)->andReturn($older);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->allows('get')->with('presets', 'presets-20261016T100000Z-aaaaaa')->andReturn(['id' => 'presets-20261016T100000Z-aaaaaa', 'data' => $older,
|
||||
'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-presets-set', 'log_id' => 2, 'size' => 1]);
|
||||
$snapshots->expects('save')->with('presets', Mockery::type('array'), 'lit/divi-design-restore')->andReturn('presets-3');
|
||||
$snapshots->expects('attachLog');
|
||||
$ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots);
|
||||
|
||||
$dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']), 'confirm_required');
|
||||
self::assertSame('removed', $dry['diff'][0]['action']);
|
||||
|
||||
$result = $ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa', 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
self::assertSame('presets-3', $result['snapshot_id']);
|
||||
}
|
||||
|
||||
public function testUnknownSnapshotAndOtherAreas(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->allows('get')->andReturn(null);
|
||||
$ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots);
|
||||
|
||||
self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found');
|
||||
self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported');
|
||||
self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviDesignSnapshotsAbilityTest extends TestCase
|
||||
{
|
||||
public function testListsTheSnapshotsOfAnArea(): void
|
||||
{
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||||
$store = Mockery::mock(SnapshotStore::class);
|
||||
$store->expects('list')->with('presets')->andReturn([
|
||||
['id' => 'presets-2', 'created' => '2026-10-16T10:00:00+00:00', 'user_id' => 1,
|
||||
'ability' => 'lit/divi-presets-set', 'log_id' => 4, 'size' => 120],
|
||||
]);
|
||||
$ability = new DiviDesignSnapshotsAbility($store);
|
||||
|
||||
self::assertTrue($ability->permission([]));
|
||||
$result = $ability->execute([]);
|
||||
|
||||
self::assertSame('presets', $result['area']);
|
||||
self::assertSame('presets-2', $result['snapshots'][0]['id']);
|
||||
self::assertTrue($ability->annotations()['readonly']);
|
||||
}
|
||||
|
||||
public function testUnknownAreaAndRights(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
$ability = new DiviDesignSnapshotsAbility(Mockery::mock(SnapshotStore::class));
|
||||
|
||||
self::assertToolError($ability->permission([]), 'forbidden');
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
self::assertToolError($ability->execute(['area' => 'seiten']), 'validation_failed');
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ final class DiviIntegrationTest extends TestCase
|
||||
'lit/divi-preset-get',
|
||||
'lit/divi-presets-set',
|
||||
'lit/divi-presets-delete',
|
||||
'lit/divi-design-snapshots',
|
||||
'lit/divi-design-restore',
|
||||
];
|
||||
|
||||
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
|
||||
|
||||
Reference in New Issue
Block a user