diff --git a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php index eaf0126..b717561 100644 --- a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php @@ -4,36 +4,36 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; -use Closure; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureSets; use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; 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). + * lit/divi-design-restore (spec §5, G6, ruling V10): 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. The area comes from the snapshot ID; its RestorePlanner brings the rights, the Divi features and + * the plan. Areas without a planner are not restorable yet. */ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures { private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D'; - private const MARKUP = 'Der Snapshot oder der aktuelle Bestand enthält Presets mit HTML/CSS, das beim Speichern ' - . 'ohne das Recht unfiltered_html verloren ginge; nichts wurde gespeichert.'; + /** @var array area => planner */ + private array $planners = []; - /** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */ public function __construct( - private DesignSystemWriter $writer, private DesignChange $change, private SnapshotStore $snapshots, - private ?Closure $unfiltered = null + RestorePlanner ...$planners ) { - $this->unfiltered ??= DesignRights::unfilteredHtml(); + foreach ($planners as $planner) { + $this->planners[$planner->area()] = $planner; + } } public function name(): string @@ -78,37 +78,42 @@ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures return false; } - /** Writes a stored snapshot back through the preset area (PresetWrite, reading it first: DesignRead). */ + /** The features of the snapshot's area; without a restorable area those of every area (fail closed). */ public function diviFeatures(array $input): array { - return [DiviFeature::DesignRead, DiviFeature::PresetWrite]; + return FeatureSets::merge(...array_map( + static fn (RestorePlanner $planner): array => $planner->features(), + $this->plannersFor($input) + )); } public function permission(array $input): bool|WP_Error { - return DesignRights::presets($this->writer) ?? true; + return $this->rights($input) ?? true; } public function execute(array $input): mixed { - $refusal = DesignRights::presets($this->writer); + $refusal = $this->rights($input); if ($refusal !== null) { return $refusal; } $id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : ''; - if (preg_match(self::ID, $id, $match) !== 1) { + $area = self::areaOf($input); + if ($area === null) { return Errors::invalidInput( 'Ungültige Snapshot-ID.', ['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3'] ); } - if ($match[1] !== 'presets') { + $planner = $this->planners[$area] ?? null; + if ($planner === null) { 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); + $snapshot = $this->snapshots->get($area, $id); if ($snapshot === null) { return Errors::notFound( sprintf('Snapshot %s gibt es nicht (mehr).', $id), @@ -116,35 +121,47 @@ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures ); } - $unfiltered = $this->unfiltered; - return $this->change->run( $this->name(), - new PresetArea($this->writer), + $planner->designArea(), $input, - static function (array $raw) use ($snapshot, $id, $unfiltered): DesignPlan|WP_Error { - $before = PresetBook::fromDivi($raw); - $after = PresetBook::fromDivi($snapshot['data']); - $refusal = DesignRights::markup( - $unfiltered, - array_merge($before->markupOutside([]), $after->markupOutside([])), - self::MARKUP - ); - if ($refusal !== null) { - return $refusal; - } - $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 - ); - } + static fn (array $raw): DesignPlan|WP_Error => $planner->plan($raw, $snapshot['data'], $id) ); } + + /** + * Spec §10: the rights of the snapshot's area; without a restorable area those of every area (fail closed). + * + * @param array $input + */ + private function rights(array $input): ?WP_Error + { + foreach ($this->plannersFor($input) as $planner) { + $refusal = $planner->rights(); + if ($refusal !== null) { + return $refusal; + } + } + + return null; + } + + /** + * @param array $input + * @return list The planner of the snapshot's area, or every planner. + */ + private function plannersFor(array $input): array + { + $planner = $this->planners[self::areaOf($input) ?? ''] ?? null; + + return $planner !== null ? [$planner] : array_values($this->planners); + } + + /** @param array $input */ + private static function areaOf(array $input): ?string + { + $id = $input['snapshot_id'] ?? null; + + return is_string($id) && preg_match(self::ID, $id, $match) === 1 ? $match[1] : null; + } } diff --git a/src/Integrations/Divi/Design/PresetRestorePlanner.php b/src/Integrations/Divi/Design/PresetRestorePlanner.php new file mode 100644 index 0000000..89f07b7 --- /dev/null +++ b/src/Integrations/Divi/Design/PresetRestorePlanner.php @@ -0,0 +1,74 @@ +unfiltered ??= DesignRights::unfilteredHtml(); + } + + public function area(): string + { + return 'presets'; + } + + public function designArea(): DesignArea + { + return new PresetArea($this->writer); + } + + public function rights(): ?WP_Error + { + return DesignRights::presets($this->writer); + } + + /** Writes the presets (PresetWrite), reading them first (DesignRead). */ + public function features(): array + { + return [DiviFeature::DesignRead, DiviFeature::PresetWrite]; + } + + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error + { + $before = PresetBook::fromDivi($current); + $after = PresetBook::fromDivi($snapshot); + $refusal = DesignRights::markup( + $this->unfiltered, + array_merge($before->markupOutside([]), $after->markupOutside([])), + self::MARKUP + ); + if ($refusal !== null) { + return $refusal; + } + $diff = PresetDiff::between($before, $after); + + return new DesignPlan( + $after->data(), + $diff, + sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, 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/RestorePlanner.php b/src/Integrations/Divi/Design/RestorePlanner.php new file mode 100644 index 0000000..c8fbf10 --- /dev/null +++ b/src/Integrations/Divi/Design/RestorePlanner.php @@ -0,0 +1,34 @@ + */ + public function features(): array; + + /** + * @param array $current The area's inventory now (DesignArea::raw(), read by DesignChange). + * @param array $snapshot The data of the snapshot $snapshotId. + */ + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error; +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index e2f090b..6183c34 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -29,6 +29,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; use LIT\MCP\Integrations\Divi\Design\PageRewriter; use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Design\PresetComposer; +use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; @@ -263,10 +264,9 @@ final class DiviIntegration implements Integration, Bootable ), new DiviDesignSnapshotsAbility(new SnapshotStore()), new DiviDesignRestoreAbility( - $this->designWriter, $this->designChange(), new SnapshotStore(), - DesignRights::unfilteredHtml() + new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()) ), new DiviPresetsAssignAbility( $this->designWriter, diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php index d07cb62..cd6f3aa 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php @@ -8,6 +8,7 @@ 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\PresetRestorePlanner; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; @@ -37,7 +38,11 @@ final class DiviDesignRestoreAbilityTest extends TestCase '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); + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, new FakeChangeLog(8)), + $snapshots, + new PresetRestorePlanner($writer) + ); $dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']), 'confirm_required'); self::assertSame('removed', $dry['diff'][0]['action']); @@ -63,10 +68,9 @@ final class DiviDesignRestoreAbilityTest extends TestCase $snapshots->shouldNotReceive('save'); return new DiviDesignRestoreAbility( - $writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots, - static fn (): bool => $unfiltered + new PresetRestorePlanner($writer, static fn (): bool => $unfiltered) ); } @@ -96,7 +100,11 @@ final class DiviDesignRestoreAbilityTest extends TestCase $writer->shouldNotReceive('savePresets'); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->shouldNotReceive('get'); - $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots); + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer) + ); $input = ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa', 'confirm' => true, 'design_rev' => 'x']; self::assertToolError($ability->permission($input), 'forbidden'); @@ -110,7 +118,11 @@ final class DiviDesignRestoreAbilityTest extends TestCase $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); + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer) + ); self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported'); diff --git a/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php b/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php new file mode 100644 index 0000000..a3d2be0 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php @@ -0,0 +1,54 @@ + $unfiltered); + } + + public function testAreaFeaturesAndDesignArea(): void + { + $planner = self::planner(); + + self::assertSame('presets', $planner->area()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::PresetWrite], $planner->features()); + self::assertInstanceOf(PresetArea::class, $planner->designArea()); + } + + public function testPlansTheWholeSnapshotWithRemovals(): void + { + $older = PresetBookTest::stored(); + unset($older['module']['divi/button']['items']['bbbbbbbbbb']); + + $plan = self::planner()->plan(PresetBookTest::stored(), $older, 'presets-20261016T100000Z-aaaaaa'); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->mayRemove); + self::assertSame([['bbbbbbbbbb', 'removed']], array_map( + static fn (array $row): array => [$row['id'], $row['action']], + $plan->diff + )); + self::assertStringStartsWith('Snapshot presets-20261016T100000Z-aaaaaa wiederherstellen – ', $plan->summary); + self::assertStringContainsString('Standard-Preset', $plan->warnings[0]); + } + + public function testMarkupNeedsUnfilteredHtml(): void + { + $plan = self::planner(false)->plan(PresetBookTest::stored(), PresetBookTest::storedWithMarkup(), 'presets-1'); + + self::assertToolError($plan, 'forbidden'); + } +}