feat: lit/divi-design-detach – Preset-Verweise aus Seiten und verschachtelten Presets entfernen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 17:09:49 +02:00
co-authored by Claude Opus 5.5
parent d4f235ae5e
commit 607e0a21ca
6 changed files with 813 additions and 0 deletions
@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset –
* from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through
* the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange,
* snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE
* confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step.
* Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c.
*/
final class DiviDesignDetachAbility implements Ability
{
private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
private const DELETE_HINT = 'Jetzt kann lit/divi-presets-delete das Preset löschen.';
private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes '
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(
private DesignSystemWriter $writer,
private DesignChange $change,
private GlobalUsages $usages,
private PageRewriter $pages,
private ?Closure $unfiltered = null
) {
$this->unfiltered ??= DesignRights::unfilteredHtml();
}
public function name(): string
{
return 'lit/divi-design-detach';
}
public function label(): string
{
return 'Divi-Preset-Verweise entfernen';
}
public function description(): string
{
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus '
. 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; '
. 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf '
. 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff '
. 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine '
. 'nennt); je Aufruf höchstens 50 Seiten, remaining nennt den Rest. Mit post_ids können nur die '
. 'genannten Seiten das Schreiben blockieren, die Obergrenze für sehr viele Seiten gilt dann nicht, und '
. 'eine genannte Seite ohne Bearbeitungsrecht steht in skipped. Danach kann lit/divi-presets-delete '
. 'das Preset löschen. ' . self::VARIABLES;
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'preset' => [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string'],
'group' => ['type' => 'string'],
'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'],
],
'required' => ['preset'],
],
'variable' => ['type' => 'string', 'description' => self::VARIABLES],
'post_ids' => PostIdsInput::schema(),
] + DesignChange::inputProperties(),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return PageRewriteReport::outputSchema([
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
'design_rev' => ['type' => ['string', 'null']],
]);
}
public function annotations(): array
{
return Annotations::write(true);
}
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;
}
if (array_key_exists('variable', $input)) {
return Errors::unsupported(
'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.',
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
);
}
$target = $this->target($input);
if ($target instanceof WP_Error) {
return $target;
}
return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target);
}
/**
* The preset to detach, resolved on the live inventory.
*
* @param array<string, mixed> $input
* @return array{scope: PresetScope, id: string, name: string, default: bool, nested: bool,
* post_ids: list<int>|null, query: PresetUsageQuery, rewrite: Closure(): NodeRewrite, change: string}|WP_Error
*/
private function target(array $input): array|WP_Error
{
$preset = $input['preset'] ?? null;
if (! is_array($preset)) {
return self::invalid(['preset' => 'Objekt mit module oder group und preset (Name oder ID) erwartet']);
}
try {
$postIds = PostIdsInput::parse($input['post_ids'] ?? null);
} catch (InvalidArgumentException $e) {
return self::invalid(['post_ids' => $e->getMessage()]);
}
try {
$scope = PresetScope::fromInput($preset, 'preset');
} catch (InvalidArgumentException $e) {
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
return self::invalid([$path => $problem]);
}
$name = is_string($preset['preset'] ?? null) ? trim($preset['preset']) : '';
if ($name === '') {
return self::invalid(['preset.preset' => 'Name oder ID des Presets erwartet']);
}
$book = PresetBook::fromDivi($this->writer->presets());
try {
$found = $book->find($scope, $name);
} catch (InvalidArgumentException $e) {
return self::invalid(['preset.preset' => $e->getMessage()]);
}
if ($found === null) {
return Errors::notFound(
sprintf('Unbekanntes %s „%s“; nichts wurde geändert.', $scope->label(), $name),
'Vorhandene Presets mit Namen und IDs liefert lit/divi-design-system (direkt: lit-divi-design-system).'
);
}
$id = (string) $found['id'];
$label = is_string($found['name'] ?? null) ? $found['name'] : $id;
return [
'scope' => $scope,
'id' => $id,
'name' => $label,
'default' => $book->defaultId($scope) === $id,
'nested' => $postIds === null && $book->nestedUsers($id) !== [],
'post_ids' => $postIds,
// Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay.
'query' => new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]),
'rewrite' => static fn (): NodeRewrite => new DetachRewrite($scope, $id),
'change' => sprintf('Entkoppeln von %s „%s“', $scope->label(), $label),
];
}
/**
* ONE confirm_required with the pages and, when other presets nest the preset, the preset step's diff and
* design_rev; a normal result when nothing references it any more.
*
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error
*/
private function dryRun(array $target): array|WP_Error
{
$preview = $this->pages->preview($target['query'], $target['rewrite'], $target['post_ids']);
if ($preview instanceof WP_Error) {
return $preview;
}
$presets = $target['nested'] ? $this->presetDryRun($target) : [];
if ($presets instanceof WP_Error) {
return $presets;
}
$notes = self::notes($target);
if ($presets === [] && PageRewriteReport::isEmpty($preview)) {
$summary = sprintf(
'Keine Verweise auf %s „%s“ gefunden – nichts zu tun.',
$target['scope']->label(),
$target['name']
);
return PageRewriteReport::nothingToDo(
$summary . (self::finished($target, $preview['skipped']) ? ' ' . self::DELETE_HINT : ''),
$preview,
$notes
);
}
$message = PageRewriteReport::dryRunMessage($preview, $target['change']);
if ($presets !== []) {
$message .= sprintf(
' Zuerst entfernt der Aufruf die Verweise in %d anderen Presets (diff, mit Snapshot); dafür die '
. 'design_rev mit confirm: true mitsenden.',
count($presets['diff'])
);
}
return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $notes]);
}
/**
* The preset step's dry run through DesignChange::run() without confirm: its confirm_required carries diff and
* design_rev; a result without error means nothing nests the preset any more.
*
* @param array<string, mixed> $target
* @return array{diff: list<array<string, mixed>>, design_rev: string}|array{}|WP_Error
*/
private function presetDryRun(array $target): array|WP_Error
{
$result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target));
if (! $result instanceof WP_Error) {
return [];
}
if (Errors::codeOf($result) !== 'confirm_required') {
return $result;
}
$data = (array) $result->get_error_data();
return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')];
}
/**
* @param array<string, mixed> $target
* @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error)
*/
private function nestedPlan(array $target): Closure
{
return function (array $raw) use ($target): DesignPlan|WP_Error {
$before = PresetBook::fromDivi($raw);
$after = $before->withoutNestedRef($target['id']);
$diff = PresetDiff::between($before, $after);
if ($diff === []) {
return new DesignPlan($after->data(), [], PresetDiff::summary([]));
}
// The whole inventory is saved: HTML/CSS of any preset would be stripped without unfiltered_html (I-2).
$refusal = DesignRights::markup($this->unfiltered, $after->markupOutside([]));
if ($refusal !== null) {
return $refusal;
}
// The page usages ride along: a blocked live post refuses before the presets are written (P8a).
return new DesignPlan(
$after->data(),
$diff,
PresetDiff::summary($diff),
$this->usages->reportFor($target['query'])
);
};
}
/**
* Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages.
*
* @param array<string, mixed> $input
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error
*/
private function confirmed(array $input, array $target): array|WP_Error
{
$presets = null;
if ($target['nested']) {
$presets = $this->change->run(
$this->name(),
new PresetArea($this->writer),
$input,
$this->nestedPlan($target)
);
if ($presets instanceof WP_Error) {
return $presets;
}
}
$wrotePresets = $presets !== null && $presets['diff'] !== [];
$pages = $this->pages->apply(
$this->name(),
$target['query'],
$target['rewrite'],
$target['post_ids'],
$target['change']
);
if ($pages instanceof WP_Error) {
return $wrotePresets ? self::afterPresets($pages, $presets) : $pages;
}
$summary = PageRewriteReport::savedSummary($pages, $target['change']);
if ($wrotePresets) {
$summary = $presets['summary'] . ' ' . $summary;
}
if ($pages['remaining'] === 0 && self::finished($target, $pages['skipped'])) {
$summary .= ' ' . self::DELETE_HINT;
}
return ToolResult::ok($summary)
->snapshotId($presets['snapshot_id'] ?? null)
->logId($presets['log_id'] ?? null)
->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], self::notes($target)))
->with('saved', $pages['saved'])
->with('remaining', $pages['remaining'])
->with('skipped', $pages['skipped'])
->with('diff', $presets['diff'] ?? [])
->with('design_rev', $presets['design_rev'] ?? null)
->toArray();
}
/**
* A page error after the preset step wrote: the client learns that the nested references are gone already.
*
* @param array<string, mixed> $presets The preset step's ToolResult.
*/
private static function afterPresets(WP_Error $error, array $presets): WP_Error
{
$data = (array) $error->get_error_data();
return Errors::make(
Errors::codeOf($error),
Errors::clientMessage($error) . sprintf(
' Die Verweise in anderen Presets sind bereits entfernt (Snapshot %s).',
(string) $presets['snapshot_id']
),
(string) ($data['hint'] ?? ''),
(int) ($data['status'] ?? 500),
array_diff_key($data, ['status' => true, 'hint' => true])
+ ['snapshot_id' => $presets['snapshot_id'], 'diff' => $presets['diff']]
);
}
/**
* Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped.
*
* @param array<string, mixed> $target
* @param list<array<string, mixed>> $skipped
*/
private static function finished(array $target, array $skipped): bool
{
return $target['post_ids'] === null && $skipped === [];
}
/**
* @param array<string, mixed> $target
* @return list<string>
*/
private static function notes(array $target): array
{
return $target['default'] ? [sprintf(self::DEFAULT_NOTE, $target['name'], $target['scope']->name)] : [];
}
/** @param array<string, string> $errors */
private static function invalid(array $errors): WP_Error
{
return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors);
}
}
@@ -200,6 +200,32 @@ final class PresetBook
return $users;
}
/**
* The inventory without nested references to group preset $groupPresetId (lit/divi-design-detach, R10): the ID
* leaves the presetId stack of every groupPresets entry, an entry left without IDs is removed. Presets that do not
* nest it stay exactly as they are.
*/
public function withoutNestedRef(string $groupPresetId): self
{
$data = $this->data;
foreach ($this->data as $kind => $records) {
foreach ($records as $name => $record) {
foreach ($record['items'] as $id => $item) {
$nested = $item['groupPresets'] ?? null;
if (! is_array($nested)) {
continue;
}
$kept = self::withoutGroupPreset($nested, $groupPresetId);
if ($kept !== $nested) {
$data[$kind][$name]['items'][$id]['groupPresets'] = $kept;
}
}
}
}
return new self($data);
}
/**
* IDs of the presets outside $touchedIds whose settings (attrs, styleAttrs, renderAttrs) hold a string with "<"
* or ">" – HTML or CSS that Divi's prepare_data() strips when a user without unfiltered_html saves the inventory
@@ -254,6 +280,28 @@ final class PresetBook
return $id;
}
/**
* @param array<array-key, mixed> $nested The groupPresets of one preset (formats §3).
* @return array<array-key, mixed>
*/
private static function withoutGroupPreset(array $nested, string $groupPresetId): array
{
foreach ($nested as $groupId => $entry) {
$ids = is_array($entry) ? (array) ($entry['presetId'] ?? []) : [];
if (! in_array($groupPresetId, $ids, true)) {
continue;
}
$left = array_values(array_filter($ids, static fn (mixed $id): bool => $id !== $groupPresetId));
if ($left === []) {
unset($nested[$groupId]);
continue;
}
$nested[$groupId]['presetId'] = $left;
}
return $nested;
}
private static function hasMarkup(mixed $value): bool
{
if (is_string($value)) {
@@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Design\DesignRights;
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\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
@@ -192,6 +193,13 @@ final class DiviIntegration implements Integration
$this->pageRewriter($loader),
$this->presetAssignment(...)
),
new DiviDesignDetachAbility(
$this->designWriter,
$this->designChange(),
$this->globalUsages(),
$this->pageRewriter($loader),
DesignRights::unfilteredHtml()
),
];
}