refactor: lit/divi-design-restore plant über RestorePlanner; PresetRestorePlanner übernimmt den bisherigen Code unverändert

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:22:05 +02:00
co-authored by Claude Sonnet 5.5
parent 62f8465e60
commit 5af188d656
6 changed files with 242 additions and 51 deletions
@@ -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<string, RestorePlanner> 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<string, mixed> $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<string, mixed> $input
* @return list<RestorePlanner> 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<string, mixed> $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;
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Restores a snapshot of the presets (Etappe 4a): the snapshot replaces the whole inventory (presets missing in it are
* removed); without unfiltered_html it is refused while the snapshot or the current inventory holds HTML/CSS that
* Divi would strip (final review I-2).
*/
final class PresetRestorePlanner implements RestorePlanner
{
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.';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(private DesignSystemWriter $writer, private ?Closure $unfiltered = null)
{
$this->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
);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* How lit/divi-design-restore writes a snapshot of one area back (spec §5, ruling V10): the area for DesignChange, the
* rights and Divi features of that area, and the plan from the current inventory to the restored one.
*/
interface RestorePlanner
{
/** The snapshot area this planner restores – one of SnapshotStore::AREAS. */
public function area(): string;
public function designArea(): DesignArea;
/** Why the current user may not restore this area (spec §10); null when allowed. */
public function rights(): ?WP_Error;
/** @return list<DiviFeature> */
public function features(): array;
/**
* @param array<array-key, mixed> $current The area's inventory now (DesignArea::raw(), read by DesignChange).
* @param array<array-key, mixed> $snapshot The data of the snapshot $snapshotId.
*/
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error;
}
+2 -2
View File
@@ -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,