feat: lit/divi-design-detach entkoppelt Variablen – Verweise in Seiten und Presets werden durch den Wert ersetzt (V9)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:14:49 +02:00
co-authored by Claude Sonnet 5.5
parent 6e9218a2bc
commit e951cc1353
5 changed files with 358 additions and 44 deletions
@@ -21,27 +21,32 @@ 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.
* lit/divi-design-detach (spec §6.8, rulings R10, V9): removes every explicit reference to one preset or one global
* variable from pages, library entries and Theme Builder layouts (DetachRewrite or VariableDetachRewrite through the
* PageRewriter) and, without post_ids, from presets that nest it (DesignChange, snapshot "presets"): a group preset
* leaves the groupPresets of other presets, a variable reference in a preset becomes the variable's value. Modules
* fall back to the next preset of their stack or the default; a variable reference becomes the value (the variable
* itself stays as it is). The dry run is ONE confirm_required with the pages and, when presets nest the target, 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. Global colors have no
* detach (ruling V9: they are only deactivated).
*/
final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
{
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.';
private const ARCHIVE_HINT = 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).';
private const NO_COLORS = 'Globale Farben lassen sich nicht entkoppeln, nur deaktivieren (lit/divi-colors-set).';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(
private DesignSystemWriter $writer,
private GlobalVariablesAdapter $variables,
private DesignChange $change,
private GlobalUsages $usages,
private PageRewriter $pages,
@@ -57,23 +62,26 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
public function label(): string
{
return 'Divi-Preset-Verweise entfernen';
return 'Divi-Preset- und Variablen-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 ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit '
. 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele '
. 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht '
. 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne '
. 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, '
. 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. '
. self::VARIABLES;
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) oder auf '
. 'eine globale Variable (variable: Name oder ID) aus Seiten, Bibliothekseinträgen und '
. 'Theme-Builder-Layouts und – bei Gruppen-Presets und Variablen – aus Presets; Module fallen auf das '
. 'nächste Preset im Stapel bzw. das Standard-Preset zurück, ein Variablenverweis wird durch den aktuellen '
. 'Wert der Variablen ersetzt (die Variable selbst bleibt; Verweise mitten in einem längeren Text bleiben '
. 'stehen und stehen als Hinweis in warnings). 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 '
. PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit post_ids können nur die genannten '
. 'Seiten das Schreiben blockieren und die Obergrenze für sehr viele Seiten gilt nicht; eine genannte '
. 'veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht verweigert den ganzen Aufruf '
. '(forbidden, blocked_ids), eine genannte andere Seite ohne Bearbeitungsrecht steht in skipped (bei mehr '
. 'als 2000 Live-Fundstellen entfällt die Vorabprüfung, dann auch eine genannte Live-Seite). Danach kann '
. 'lit/divi-presets-delete das Preset löschen bzw. lit/divi-variables-set die Variable archivieren. '
. self::NO_COLORS;
}
public function inputSchema(): array
@@ -90,7 +98,10 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
],
'required' => ['preset'],
],
'variable' => ['type' => 'string', 'description' => self::VARIABLES],
'variable' => [
'type' => 'string',
'description' => 'Name oder ID einer globalen Variablen (statt preset). ' . self::NO_COLORS,
],
'post_ids' => PostIdsInput::schema(),
] + DesignChange::inputProperties(),
'additionalProperties' => false,
@@ -117,11 +128,12 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
/**
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
* nested in other presets (PresetWrite).
* nested in presets (PresetWrite); a variable is read and its rights checked through GlobalVariablesAdapter
* (VariableWrite).
*/
public function diviFeatures(array $input): array
{
return [
$features = [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
@@ -132,26 +144,25 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles,
];
return array_key_exists('variable', $input) ? [...$features, DiviFeature::VariableWrite] : $features;
}
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;
}
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).'
);
if (array_key_exists('variable', $input) && array_key_exists('preset', $input)) {
return self::invalid(['variable' => 'entweder preset oder variable angeben, nicht beides']);
}
$target = $this->presetTarget($input);
$target = array_key_exists('variable', $input) ? $this->variableTarget($input) : $this->presetTarget($input);
if ($target instanceof WP_Error) {
return $target;
}
@@ -159,6 +170,88 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target);
}
/**
* Spec §10: presets need the preset rights; a variable also the variable rights – the preset step writes presets
* either way (V9).
*
* @param array<string, mixed> $input
*/
private function rights(array $input): ?WP_Error
{
$refusal = DesignRights::presets($this->writer);
if ($refusal !== null || ! array_key_exists('variable', $input)) {
return $refusal;
}
return DesignRights::variables($this->writer, $this->variables);
}
/**
* The global variable to detach, resolved on the live inventory; its current value replaces the references
* (V9: whether it is active or archived does not matter).
*
* @param array<string, mixed> $input
*/
private function variableTarget(array $input): DetachTarget|WP_Error
{
$needle = is_string($input['variable'] ?? null) ? trim($input['variable']) : '';
if ($needle === '') {
return self::invalid(['variable' => 'Name oder ID der Variablen erwartet']);
}
try {
$postIds = PostIdsInput::parse($input['post_ids'] ?? null);
} catch (InvalidArgumentException $e) {
return self::invalid(['post_ids' => $e->getMessage()]);
}
if (str_starts_with($needle, 'gcid-')) {
return Errors::unsupported(
sprintf('„%s“ ist eine globale Farbe; nichts wurde geändert.', $needle),
self::NO_COLORS
);
}
$book = VariableBook::fromDivi($this->variables->read());
try {
$found = $book->find($needle);
} catch (InvalidArgumentException $e) {
return self::invalid(['variable' => $e->getMessage()]);
}
if ($found === null) {
return Errors::notFound(
sprintf('Unbekannte Variable „%s“; nichts wurde geändert.', $needle),
'Vorhandene Variablen mit Namen und IDs liefert lit/divi-design-system (direkt: '
. 'lit-divi-design-system). ' . self::NO_COLORS
);
}
$id = $found['id'];
$type = $found['type'];
$entry = $book->entry($type, $id) ?? [];
$label = is_string($entry['label'] ?? null) && $entry['label'] !== '' ? $entry['label'] : $id;
$value = $entry['value'] ?? null;
if (! is_string($value)) {
return Errors::unsupported(
sprintf('Variable „%s“ (Typ %s) hat keinen Textwert; nichts wurde geändert.', $label, $type),
'Verweise auf diese Variable im Divi-Builder lösen.'
);
}
$nested = $postIds === null && PresetBook::fromDivi($this->writer->presets())->referencing($id) !== [];
$affixes = $type === 'strings';
return new DetachTarget(
sprintf('Variable „%s“', $label),
$postIds,
new ReferenceUsageQuery([$id]),
static fn (): NodeRewrite => new VariableDetachRewrite($id, $value, $type),
$nested
? static fn (PresetBook $presets): PresetBook => $presets->withVariableResolved($id, $value, $affixes)
: null,
'Presets',
[],
self::ARCHIVE_HINT
);
}
/**
* The preset to detach, resolved on the live inventory.
*
@@ -22,6 +22,7 @@ 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\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
@@ -274,6 +275,7 @@ final class DiviIntegration implements Integration, Bootable
),
new DiviDesignDetachAbility(
$this->designWriter,
new GlobalVariablesAdapter(),
$this->designChange(),
$this->globalUsages(),
$this->pageRewriter($loader),