diff --git a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php new file mode 100644 index 0000000..9df7c0a --- /dev/null +++ b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php @@ -0,0 +1,125 @@ + '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 + ); + } + ); + } +} diff --git a/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php b/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php new file mode 100644 index 0000000..96da6ea --- /dev/null +++ b/src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php @@ -0,0 +1,95 @@ + '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']]; + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 018d007..afefe51 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -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()), ]; } diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php new file mode 100644 index 0000000..db24ad3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php @@ -0,0 +1,54 @@ +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'); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php new file mode 100644 index 0000000..ebc137d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 4fe1f0a..e74d926 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -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