From ca3bbf5ad1899864991466dc2d995bc0bffb6653 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 16:34:17 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Plan=20Etappe=204b=20=E2=80=93=20Preset?= =?UTF-8?q?s=20an=20Seiten=20zuweisen,=20Massenzuweisung,=20detach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-18-etappe-4b-zuweisen.md | 9289 +++++++++++++++++ 1 file changed, 9289 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-18-etappe-4b-zuweisen.md diff --git a/docs/superpowers/plans/2026-10-18-etappe-4b-zuweisen.md b/docs/superpowers/plans/2026-10-18-etappe-4b-zuweisen.md new file mode 100644 index 0000000..0e0fe0a --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-etappe-4b-zuweisen.md @@ -0,0 +1,9289 @@ +# Etappe 4b – Designsystem: Presets an Seiten zuweisen – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Der KI-Assistent weist Modulen Preset-Stapel und Gruppen-Presets zu (`lit-divi-insert`, `lit-divi-update`, `lit-content-create`), erfährt dabei, welche lokalen Werte ein Preset überdecken, und entfernt sie auf Wunsch (`clear_overrides`); er weist ein Preset site-weit zu (`lit/divi-presets-assign`) und entfernt Preset-Verweise überall (`lit/divi-design-detach`) – mit Probelauf, `confirm`, höchstens 50 Seiten je Aufruf, Revision je Seite und Protokoll. Die Outline zeigt Gruppen-Presets mit Namen. + +**Architecture:** Reine Bausteine in `Divi/Design/`: `GroupKeys` (Gruppenschlüssel → `groupId`), `PresetAssignment` (`preset`/`group_presets` → `modulePreset`/`groupPreset`), `PresetOverrides` (lokale Blätter gegen Preset-Blätter), `TreeRewrite` mit `NodeRewrite`-Strategien `AssignRewrite`/`DetachRewrite`, `ModuleUsageQuery`. Der Composer bekommt `PresetAssignment` als einzige Stelle für Preset-Zuweisungen (Engstelle D7 bleibt der Composer). Ein WordPress-seitiger `PageRewriter` findet Fundstellen über das vorhandene `GlobalUsages::rowsFor()`/`reportFor()` (keine neue Klasse `DesignUsages`), lädt jede Seite frisch über `DiviPageLoader`, schreibt mit `TreeRewrite` und speichert über `DiviSaver` (Revision, Cache) plus `ChangeLog`. `lit/divi-design-detach` nutzt für verschachtelte Verweise in anderen Presets die 4a-Pipeline `DesignChange` (Snapshot `presets`) und danach den `PageRewriter`. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `$wpdb`), Divi 5.15.1 (`GlobalPreset`, `ModuleRegistration` – nur über vorhandene Adapter), `wordpress/mcp-adapter` 0.5, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes. + +**Spec:** `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§6.5–§6.8, §10, §11, §13, §14 Schritte 4–5), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G2, G4, G5, G6, G8, G9, A6, A7 – verbindlich), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md`, Fixtures `tests/Fixtures/divi/stage4/` (`presets.json`, `page--designsystem.html`), Seed `tests/Integration/divi-seed-stage4-design.php`. Plan 4a `docs/superpowers/plans/2026-10-16-etappe-4a-presets.md` als Vorbild für Muster (der Code ist maßgeblich, wo er vom 4a-Plan abweicht). + +**Voraussetzung:** Branch `etappe-4`, Stand 3e77450 (4a fertig). 2454 Unit-Tests grün (`composer test`), `composer lint` sauber. + +**Teil von Etappe 4:** 4a (fertig) → **4b (dieser Plan)** → 4c Farben und Variablen (inkl. `detach` für Variablen) → 4d Theme-Optionen und Schriften → 4e Schleifen-Inhalte, Guides, Audit, Version. + +## Global Constraints + +- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen alles, was sie anlegen, im EXIT-Trap wieder ab. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber. +- **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe nur in Adaptern aus `DiviAdapters::CLASSES` mit Manifest-Eintrag. 4b braucht **keinen neuen Divi-Aufruf**: Gruppen je Modul kommen aus dem vorhandenen `DesignSystemWriter::groupsOf()`, Presets aus `DesignSystemWriter::presets()`, Speichern über `DiviSaver`. `DiviBoundaryTest` bleibt grün, ohne Manifest-Änderung. Ergibt sich doch ein Divi-Aufruf: Task anhalten und melden (NEEDS_CONTEXT). +- `src/Integrations/Divi/BlockTree/*`, `src/Integrations/Divi/Compose/*` und die neuen reinen Klassen `GroupKeys`, `PresetAssignment`, `PresetOverrides`, `NodeRewrite`, `TreeRewrite`, `AssignRewrite`, `DetachRewrite` bleiben reines PHP ohne WordPress-Aufrufe (Abhängigkeiten als `Closure` oder Objekt injiziert). `ModuleUsageQuery` darf wie `PresetUsageQuery` nur `$db->prepare()`/`$db->esc_like()` am übergebenen `\wpdb` nutzen. +- **Eine Engstelle für Client-Werte (D7):** Preset-Zuweisungen an Module laufen ausschließlich durch `PresetAssignment`, das der `Composer` aufruft (Einzelmodul) bzw. `AssignRewrite` (Massenzuweisung). Preset-IDs passieren den `ValueCleaner` (`modulePreset`/`groupPreset`) wie heute in `Composer::preset()`; Gruppen-Schlüssel (`groupId`) und `groupName` stammen nur aus Divis Gruppenliste (`groupsOf`), nie vom Client. +- **Site-weite Änderungen (G2, Spec §4/§6.6):** Ohne `confirm: true` nie schreiben; Probelauf = `Errors::confirmRequired()` mit Seiten, Knoten, Überdeckungen, `blocked_ids`. Ist eine Live-Fundstelle blockiert (ohne `edit_post`, unsichtbar, über `HARD_CAP`), wird nichts geschrieben (wie `GlobalEdits`). Seiteninhalte bekommen keinen Snapshot (G6), sondern eine WordPress-Revision je Seite über `DiviSaver`. +- **Grenze je Aufruf (A6):** `PageRewriter::PAGE_LIMIT = 50` Seiten werden je Aufruf mit `confirm` geschrieben; die Antwort nennt `remaining`. Beide Operationen sind wiederholbar, weil ihre Filter bereits geänderte Module nicht mehr treffen. +- **Rechte (Spec §10):** Einzelmodul-Zuweisung = Rechte der vorhandenen Divi-Schreib-Tools (keine zusätzlichen). `lit/divi-presets-assign` und `lit/divi-design-detach` = `DesignRights::presets($writer)` (`edit_theme_options` + Visual Builder) **und** `edit_post` je Live-Fundstelle; Theme-Builder-Fundstellen nach `ThemeBuilderAccess` (Ruling Q17). +- **Exposition:** Neue Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe `divi`, `Annotations::write`. `docs/guides/overview.md` „Direkt sichtbar:“ bleibt unverändert. Texte nennen indirekte Tools `lit/` (ToolNamingTest). +- **Sprache:** Meldungen, Hinweise, Guides auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. +- Tests: Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery, `assertToolError`); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD: Test zuerst, RED-Lauf mit erwarteter Fehlermeldung, dann Code. Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh` (läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`). +- Commits: `git commit -m "" -m "Co-Authored-By: Claude "` mit dem Namen des ausführenden Modells (im Folgenden „Commit wie in den Global Constraints“). Nur die Dateien des Tasks stagen. + +## Entscheidungen für 4b (Rulings, verbindlich) + +- **R1 Fundstellen:** Keine Klasse `DesignUsages`. Fundstellen = `GlobalUsages::reportFor(UsageQuery)` (Rechte, `blocked`, `tooMany`) + `GlobalUsages::rowsFor(UsageQuery)` (Kandidatenliste, `ID ASC`). Neue Suche `ModuleUsageQuery` für „alle Module eines Typs“. +- **R2 Stapel:** `preset` = String | Liste von 1–10 Strings | `null`. Liste wird in dieser Reihenfolge als `modulePreset` gespeichert; doppelte IDs nach Auflösung → Fehler; leere Liste → Fehler („null entfernt das Preset“). Tool-Texte sagen: „Bei gleicher Priorität gewinnt das letzte Preset im Stapel (Divi-Verhalten).“ Einzelner String bleibt `[id]` wie heute. +- **R3 Gruppen-Schlüssel:** `group_presets`-Schlüssel = Kurzname (`font-body`), voller Name (`divi/font-body`) oder Attributpfad (`content.decoration.bodyFont`). Hat das Modul mehrere Gruppen mit demselben Namen und ist der Schlüssel kein Pfad → Fehler mit den Pfaden zur Auswahl. Werte: String | Liste (1–10) | `null` (entfernt den Eintrag dieser Gruppe; leeres `groupPreset` wird entfernt). `GroupKeys` ersetzt die private Suche in `PresetComposer` (Verhalten dort unverändert, Meldungstexte dort unverändert). +- **R4 Überdeckungen (G9):** Verglichen wird auf Blatt-Ebene „Pfad | Breakpoint | Zustand | Unterpfad“ (Divi führt mit `array_replace_recursive` zusammen; ein lokales `padding.top` überdeckt kein Preset-`padding.bottom`). Gezählt werden nur **ausdrücklich zugewiesene** Presets des Blocks (Stapel aus `modulePreset`, Einträge aus `groupPreset`, verschachtelte `groupPresets` der zugewiesenen Modul-Presets), keine Standard-Presets. Gruppen-Preset-Blätter werden vom gespeicherten `groupId` des Presets auf den `groupId`-Schlüssel des Blocks umgehängt. Warnung nur, wenn der lokale Wert **abweicht**; `clear_overrides: true` entfernt alle lokalen Blätter auf Preset-Blättern (gleiche Werte sind ohnehin wirkungslos) und räumt leere Eltern auf. +- **R5 Warnungstext:** `: lokale Werte überdecken „“: [ und N weitere] – clear_overrides: true entfernt sie.` Pfade als `` bei `desktop`/`value`, sonst ` ([, ])`. Ein Preset mit mehreren Quellen: je Preset eine Zeile. +- **R6 Outline:** Zeile bekommt das Pflichtfeld `group_presets: list` mit Einträgen `": [, ]"` (Kurzname = `groupName` ohne `divi/`, Namen über `presetNames`, sonst ID); Text `{Gruppen-Presets: font-body: Fließtext; spacing: Abstand Karte}` nach `{Preset: …}`, nur wenn nicht leer. `lit-divi-node-get` liefert `group_presets: list<{group_id, group, presets: list<{id, name}>}>`. `Fingerprint` bleibt unverändert. +- **R7 `clear_overrides` beim Einfügen:** erlaubt; wirkt nach `set`/`attrs` desselben Knotens (entfernt also Werte, die der Aufruf selbst gesetzt hat und die das Preset überdecken). Nicht-boolescher Wert → Fehler. +- **R8 Seiten-Umschreiber:** Globale Modul-Instanzen (opake Blöcke) werden nie umgeschrieben; ihr Bibliothekseintrag ist eine eigene Fundstelle und wird wie jede Seite umgeschrieben (Speicher-Scope wie in `DiviWriter`). Instanzen, deren Modultyp passt bzw. deren Inhalt die Preset-ID enthält, werden als `skipped` mit Hinweis auf den Bibliothekseintrag gemeldet. Seiten, die `loadWritable()` ablehnt, landen in `skipped` mit der Fehlermeldung. `remaining` = Zahl weiterer Seiten mit Änderungen nach den ersten 50. Fail-closed-Kandidaten, deren Inhalt sich nicht parsen lässt, sind `skipped` (nicht `remaining`), damit Wiederholungen enden. +- **R9 Kein `unfiltered_html`-Zwang im Umschreiber:** Er ändert nur Preset-Verweise und entfernt lokale Werte; er fügt keine Client-Inhalte ein. Speichern wie die vorhandenen Schreib-Tools über `DiviSaver` (kses-Fenster). +- **R10 detach (Spec §6.8, nur Presets):** Eingabe `variable` → `Errors::unsupported()` mit Hinweis „Variablen folgen mit Etappe 4c.“. Ohne `post_ids` werden zuerst die verschachtelten Verweise in anderen Presets über `DesignChange` (Bereich `presets`, Snapshot) entfernt, danach die Seiten (bis 50). Weitere Aufrufe finden keine verschachtelten Verweise mehr (leerer Diff → nur Seiten). Mit `post_ids` nur die Seiten. Standard-Presets werden nicht „entkoppelt“ (Divi wendet sie implizit an); detach eines Standard-Presets entfernt nur ausdrückliche Verweise und sagt das. +- **R11 Standard-Gruppen-Presets:** `PresetUsageMatcher` erkennt implizite Nutzung eines Standard-Gruppen-Presets: Block ohne ausdrücklichen `groupPreset`-Eintrag für einen `groupId` dieses `groupName`, dessen Modul laut `groupsOf` die Gruppe hat. Kandidaten-SQL dafür fail closed `post_content LIKE '%wp:divi/%'`. Der 4a-Hinweis „diese Seiten sind nicht aufgelistet“ entfällt. + +## Review Focus + +1. **Stapel und Gruppen über die Engstelle:** `preset: ["A","B"]`, `group_presets: {"font-body": "Fließtext"}` in insert/update/content-create; unbekannter, mehrdeutiger, doppelter Name, Gruppe fehlt am Modul, Gruppe mehrdeutig → Fehler, nichts gespeichert → `PresetAssignmentTest`, `ComposerTest` (Tasks 3, 4). +2. **Überdeckung:** lokaler abweichender Wert auf einem Preset-Blatt → Warnung mit Knoten-ID; `clear_overrides: true` entfernt genau diese Blätter, sonst nichts; Gruppen-Preset auf anderem `groupId` umgehängt → `PresetOverridesTest` (Task 2), `WarningsTest` (Task 5). +3. **Massenzuweisung ohne `confirm`** schreibt nichts; mit blockierter Live-Seite schreibt nichts; 51 Seiten → 50 geschrieben, `remaining: 1`; zweiter Aufruf schreibt die letzte und meldet `remaining: 0` → `PageRewriterTest` (Task 8), `DiviPresetsAssignAbilityTest` (Task 9). +4. **Teilerfolg:** Speichern der dritten Seite scheitert → zwei Revisionen in der Antwort, Code `partial_write`, Protokoll je gespeicherter Seite → `PageRewriterTest::testFailedPageIsPartial` (Task 8). +5. **Globale Module:** Instanz wird nicht umgeschrieben und als `skipped` gemeldet; der Bibliothekseintrag wird umgeschrieben → `TreeRewriteTest`, `PageRewriterTest` (Tasks 7, 8). +6. **detach:** verschachtelter Verweis in anderem Preset wird über die Pipeline (Snapshot) entfernt, danach Seiten; `rev_mismatch` bei veralteter `design_rev` schreibt weder Presets noch Seiten; danach ist `lit/divi-presets-delete` möglich → `DiviDesignDetachAbilityTest` (Task 10), Smoke (Task 11). +7. **Rechte:** Autor ohne `edit_theme_options` → `forbidden` für assign/detach; Redakteur ohne `edit_post` auf einer Live-Fundstelle → nichts geschrieben, `blocked_ids` → Tasks 8–10. + +--- + +## File Structure + +``` +src/Integrations/Divi/ + DiviIntegration.php (geändert) Verdrahtung PresetAssignment, PageRewriter, neue Tools + BlockTree/NodeSummary.php (geändert) groupPresets() + BlockTree/Outline.php (geändert) group_presets in Zeile und Text + Compose/Composer.php (geändert) preset-Stapel, group_presets, clear_overrides über PresetAssignment + Compose/Warnings.php (geändert) Überdeckungswarnungen + Pages/DiviComposition.php (geändert) baut Composer/Warnings mit PresetAssignment/PresetOverrides + Pages/DiviUpdateAbility.php (geändert) Schema preset|list, group_presets, clear_overrides + Pages/DiviInsertAbility.php (geändert) Beschreibung + Pages/DiviOutlineAbility.php (geändert) Ausgabeschema group_presets + Pages/DiviNodeGetAbility.php (geändert) group_presets + Design/GroupKeys.php (neu) Gruppenschlüssel → groupId + Design/PresetAssignment.php (neu) preset/group_presets → modulePreset/groupPreset + Design/PresetOverrides.php (neu) Überdeckungen finden, beschreiben, entfernen + Design/PresetComposer.php (geändert) nutzt GroupKeys + Design/PresetBook.php (geändert) withoutNestedRef() + Design/PresetUsageMatcher.php (geändert) Standard-Gruppen-Presets (R11) + Design/PresetUsageQuery.php (geändert) Kandidaten für Standard-Gruppen-Presets + Design/ModuleUsageQuery.php (neu) alle Module eines Typs + Design/NodeRewrite.php (neu) Interface: ein Block → neue Attribute + Design/TreeRewrite.php (neu) NodeRewrite über einen Baum + Design/AssignRewrite.php (neu) Massenzuweisung je Block + Design/DetachRewrite.php (neu) Preset-ID aus Stapeln entfernen + Design/PageRewriter.php (neu) Fundstellen → Probelauf / Seiten schreiben (≤ 50) + Design/DiviPresetsAssignAbility.php (neu) lit/divi-presets-assign + Design/DiviDesignDetachAbility.php (neu) lit/divi-design-detach + Design/DiviPresetsDeleteAbility.php (geändert) DETACH_HINT ohne „ab Etappe 4b“, Standard-Gruppen-Fundstellen + Design/DiviPresetsSetAbility.php (geändert) Standard-Gruppen-Warnung entfällt (R11) +src/Integrations/WordPress/Content/ContentCreateAbility.php (geändert) Beschreibung +docs/guides/divi-shortform.md, divi-design-system.md, divi-placeholders.md, divi-structure.md, workflows.md (geändert) +docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md (geändert) Nachtrag 4b +tests/Unit/Integrations/Divi/... (neu/geändert je Task) +tests/Support/ComposeFixture.php, tests/Support/DiviFixtures.php (geändert) Presets mit attrs, echte Gruppen +tests/Integration/divi-presets-assign-smoke.sh (neu) +``` + +## Tasks und Schnittstellen (Überblick) + +Ausführendes Modell je Task (Empfehlung): Haiku = fertiger Code im Plan, Sonnet = Integration/Abwägung, Opus = schwierige Logik. + +| Task | Inhalt | Modell | +|---|---|---| +| 1 | `NodeSummary::groupPresets()`, Outline/Node-Get zeigen Gruppen-Presets | Haiku | +| 2 | `PresetOverrides` (rein) | Sonnet | +| 3 | `GroupKeys`, `PresetAssignment` (rein), `PresetComposer` auf `GroupKeys` | Sonnet | +| 4 | Composer: Stapel, `group_presets`, `clear_overrides`; Verdrahtung, Schemas, Beschreibungen | Sonnet | +| 5 | `Warnings`: Überdeckungswarnungen | Haiku | +| 6 | `ModuleUsageQuery`; Standard-Gruppen-Presets in Matcher/Query; 4a-Hinweise anpassen | Sonnet | +| 7 | `NodeRewrite`, `TreeRewrite`, `AssignRewrite`, `DetachRewrite` (rein) | Sonnet | +| 8 | `PageRewriter` | Opus | +| 9 | `lit/divi-presets-assign` | Sonnet | +| 10 | `lit/divi-design-detach`, `PresetBook::withoutNestedRef()` | Opus | +| 11 | Guides, Entscheidungsnotiz, Smoke `divi-presets-assign-smoke.sh` | Sonnet | + +### Verbindliche Signaturen (alle Tasks halten sich daran) + +```php +// Task 1 – BlockTree/NodeSummary.php +/** @return array}> groupId => Eintrag; leere IDs gefiltert, + * Einträge ohne gültigen groupName oder ohne IDs ausgelassen */ +public static function groupPresets(Block $block): array; + +// Task 2 – Design/PresetOverrides.php (rein) +final class PresetOverrides +{ + /** @param Closure(string $id): ?array $item gespeicherter Preset-Eintrag (Modul oder Gruppe) oder null */ + public function __construct(private Closure $item); + /** Blätter "pfad|breakpoint|zustand|unterpfad" (unterpfad leer möglich) => Wert; META_KEYS ausgelassen */ + public static function leaves(array $attrs): array; + /** @return array> preset-id => überdeckende Blätter (nur abweichende Werte), Reihenfolge stabil */ + public function find(array $attrs): array; + /** attrs ohne alle lokalen Blätter auf Preset-Blättern; leere Eltern entfernt; META_KEYS unverändert */ + public function clear(array $attrs): array; + /** R5-Warnung(en) für einen Knoten; $names: id => Name. Leer, wenn nichts überdeckt. */ + public function describe(string $nodeId, array $attrs, array $names): array; // list +} + +// Task 3 – Design/GroupKeys.php (rein) +final class GroupKeys +{ + /** @param array $groups groupId => groupName (aus groupsOf) + * @return string groupId @throws \InvalidArgumentException mit deutscher Meldung (unbekannt / mehrdeutig) */ + public static function resolve(array $groups, string $key): string; +} + +// Task 3 – Design/PresetAssignment.php (rein) +final class PresetAssignment +{ + /** @param Closure(): PresetBook $book (lazy, höchstens einmal aufgerufen) + * @param Closure(string $module): array $groupsOf + * @param Closure(string $id): bool $validId true, wenn die ID den ValueCleaner unverändert passiert */ + public function __construct(Closure $book, Closure $groupsOf, Closure $validId); + /** Wendet 'preset' und 'group_presets' aus $spec an (nur vorhandene Schlüssel). + * @return array{attrs: array, errors: array} Fehler-Pfade wie im Composer: "$path.preset", + * "$path.preset.", "$path.group_presets." */ + public function apply(string $module, array $attrs, array $spec, string $path): array; + public function clearOverrides(array $attrs): array; // PresetOverrides::clear über dasselbe Book + public function overrides(): PresetOverrides; // gleiche Instanz für Warnungen + /** id => Name aller Presets (für Warnungen) */ + public function names(): array; +} + +// Task 6 – Design/ModuleUsageQuery.php +final class ModuleUsageQuery implements UsageQuery +{ + public function __construct(private string $module); // z. B. 'divi/button' +} +// Task 6 – PresetUsageMatcher: neuer optionaler Parameter +public function __construct(private Parser $parser = new Parser(), private ?Closure $groupsOf = null); + +// Task 7 – Design/NodeRewrite.php, TreeRewrite.php, AssignRewrite.php, DetachRewrite.php (rein) +interface NodeRewrite +{ + /** Neue Attribute für $block oder null, wenn er bleibt, wie er ist (dann auch keine Notiz). */ + public function rewrite(Block $block): ?array; + /** Hinweise zu $block nach rewrite() (Überdeckungen), $nodeId = Pfad-ID. */ + public function notes(Block $block, string $nodeId): array; // list + /** Ob eine opake globale Instanz für diese Änderung relevant wäre (dann skipped). */ + public function concernsInstance(Block $instance): bool; +} +final class TreeRewrite +{ + public function __construct(private NodeRewrite $rewrite); + /** Ändert Blöcke im Index an Ort und Stelle. + * @return array{changed: list, warnings: list, + * skipped: list} */ + public function apply(NodeIndex $index): array; +} +final class AssignRewrite implements NodeRewrite +{ + /** @param array{preset?: mixed, group_presets?: mixed} $spec bereits geprüft (PresetAssignment ohne Fehler) */ + public function __construct( + private string $module, private PresetAssignment $assignment, private array $spec, + private bool $onlyWithoutPreset = false, private ?string $onlyWithPresetId = null, private bool $clearOverrides = false + ); +} +final class DetachRewrite implements NodeRewrite +{ + public function __construct(private PresetScope $scope, private string $id); +} + +// Task 8 – Design/PageRewriter.php +final class PageRewriter +{ + public const PAGE_LIMIT = 50; + public function __construct(GlobalUsages $usages, DiviPageLoader $loader, DiviSaver $saver, ChangeLog $log); + /** Probelauf. @param Closure(): NodeRewrite $rewrite frische Strategie je Seite; $postIds null = alle + * @return array{pages: list,warnings:list}>, + * pages_count:int, nodes_count:int, skipped:list, + * blocked_ids:list, hidden_count:int}|WP_Error (pages höchstens 50 gelistet) */ + public function preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error; + /** Schreibt höchstens PAGE_LIMIT Seiten. + * @return array{saved: list, remaining:int, + * warnings:list, skipped:list}|WP_Error + * WP_Error: forbidden (blockiert), unsupported (tooMany), partial_write (mit data.saved) */ + public function apply(string $ability, UsageQuery $query, Closure $rewrite, ?array $postIds, string $summary): array|WP_Error; +} + +// Task 10 – Design/PresetBook.php +/** Bestand ohne den verschachtelten Verweis auf $groupPresetId in allen Presets (leere groupPresets-Einträge entfernt). */ +public function withoutNestedRef(string $groupPresetId): self; +``` + + +### Abstimmung zwischen den Tasks (gilt vor den Einzelabschnitten) + +Beim Ausarbeiten der Tasks wurden die Signaturen oben an diesen Stellen ergänzt; die Task-Abschnitte setzen sie um: + +- **Task 3:** `GroupKeys` hat die öffentlichen Konstanten `UNKNOWN` und `AMBIGUOUS` (Exception-Codes). `PresetComposer` + nutzt `GroupKeys`; gleichnamige Gruppen in einem Modul sind dort jetzt ein Fehler statt still der ersten Gruppe + (R3 gilt überall). Fehlertexte für einzelne Namen bleiben bytegleich mit dem heutigen `Composer::preset()`. +- **Task 4:** `ValueCleaner::keepsId(string $id): bool` (neu, die frühere ID-Prüfung aus `Composer::preset()`) ist die + `$validId`-Closure. Der `Composer` bekommt `PresetAssignment` als Pflichtparameter an der Stelle von + `DesignSystem`. `DiviComposition::__construct(…, ?DesignSystemWriter $presetSource = null)` und + `DiviComposition::presets(): PresetAssignment`; `DiviIntegration` baut alle Compositions über die private + `composition()`. `ComposeFixture::bookOf()`/`presetAssignment()` (Task 5 erweitert `presetAssignment()` um einen + ladenden Bestand). +- **Task 5:** `Warnings::__construct(DesignSystem $design, ?PresetAssignment $presets = null)`. +- **Task 6:** `PresetUsageMatcher::DEFAULT_IDS` wird `public`; neu `PresetUsageQuery::withGroups(Closure $groupsOf): + self`. `groupsOf` kommt in den Preset-Abilities aus dem schon injizierten `DesignSystemWriter`, nicht aus + `DiviIntegration`. +- **Task 8:** `PageRewriter` ist **nicht final** (Mock in Tasks 9/10). `preview()` liefert zusätzlich + `blocked_hidden_count: int`; neue Konstante `PageRewriter::NO_CHANGE`; `apply()`-Fehler `forbidden` trägt + `blocked_ids` (+ `blocked_hidden_count`). Fabrik `private function pageRewriter(DiviPageLoader $loader)` in + `DiviIntegration`. Mit `post_ids` blockieren nur die genannten Seiten. +- **Task 9:** neue reine Helfer `Design/PostIdsInput` (`schema()`, `parse(mixed): ?list`, 1–50 IDs) und + `Design/PageRewriteReport` (Texte und Ausgabeschema rund um den `PageRewriter`). Ein leerer Probelauf ist ein + normales Ergebnis („nichts zu tun“), kein `confirm_required`. `preset: null` bzw. `null` in `group_presets` → + `validation_failed` mit Hinweis auf `lit/divi-design-detach`. + Konstruktoren: `DiviPresetsAssignAbility(DesignSystemWriter, PageRewriter, Closure(): PresetAssignment)`, + `DiviDesignDetachAbility(DesignSystemWriter, DesignChange, GlobalUsages, PageRewriter, ?Closure $unfiltered = null)`. +- **Task 10:** Eingabe `preset` ist ein Objekt `{module|group, preset}` (wie die Items von `lit/divi-presets-delete`). +- **Testzahlen** in den RED/GREEN-Schritten sind Richtwerte (die Teile wurden parallel gegen den Stand 3e77450 + geschrieben); maßgeblich ist „alle grün“. + +--- + +### Task 1: `NodeSummary::groupPresets()` – Outline und Node-Get zeigen Gruppen-Presets (R6) + +**Files:** +- Modify: `src/Integrations/Divi/BlockTree/NodeSummary.php` (neue Methode `groupPresets()`, private Hilfe `idList()`) +- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (Zeilenfeld `group_presets`, Text `{Gruppen-Presets: …}`) +- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php` (Beschreibung, Ausgabeschema) +- Modify: `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` (Feld `group_presets`, Beschreibung, Ausgabeschema) +- Create: `tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php` +- Modify: `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php` (ein bestehender Erwartungswert, zwei neue Tests) +- Modify: `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` (Hilfsmethode, zwei neue Tests) + +**Interfaces:** +- Consumes: `Block::attrs()`, `Block::create()`, `Outline::__construct(array $presetNames, bool $globalIds)` (vorhanden), `DesignSystem::fromDivi()` und `DesignSystem::presetNames()` (vorhanden; Gruppen-Presets stehen mit ihrer ID und ihrem Namen in `presetNames()`), Fixtures `tests/Fixtures/divi/stage4/page--designsystem.html` und `presets.json` (vorhanden, unverändert). +- Produces: + - `NodeSummary::groupPresets(Block $block): array` – `array}>`, Schlüssel = `groupId` (Attributpfad), Reihenfolge wie im Block; leere IDs gefiltert, `presetId` als String (alt) oder Liste gelesen; Einträge ohne nicht-leeren `groupName` (String) oder ohne ID ausgelassen; fehlendes oder kaputtes `groupPreset` → `[]`. + - `Outline::rows()`-Zeile: neues Pflichtfeld `group_presets: list`, direkt nach `presets`; Einträge `": [, ]"` (Kurzname = `groupName` ohne `divi/`; Name aus `$presetNames`, sonst die ID), je `groupId` ein Eintrag. + - `Outline::text()`: ` {Gruppen-Presets: font-body: Fließtext; spacing: Abstand Karte}` direkt nach `{Preset: …}`, nur wenn nicht leer. + - `lit-divi-outline`: Knoten tragen `group_presets` (Pflichtfeld im Ausgabeschema). + - `lit-divi-node-get`: `group_presets: list<{group_id: string, group: string, presets: list<{id: string, name: string}>}>`; `group` ist der Kurzname (der Schlüssel, den `group_presets` beim Schreiben annimmt, R3), `name` ist `''`, wenn das Preset nicht mehr existiert (wie bei `presets`). Immer vorhanden, ohne Gruppen-Presets `[]`. +- `Fingerprint` bleibt unverändert (R6); `NodeSummary::presetIds()` verhält sich unverändert (nur auf `idList()` umgestellt). + +Bestehender Test, der sich ändert: `OutlineTest::testRowsAndTextOfAPage` vergleicht die Zeile `$rows[5]` exakt mit `assertSame`; die Reihenfolge der Schlüssel zählt. Alt: `'presets' => ['Hero-Titel'], 'children' => 0, …` → neu: `'presets' => ['Hero-Titel'], 'group_presets' => [], 'children' => 0, …` (Schritt 3). Sonst vergleicht kein Test eine ganze Zeile. + +- [ ] **Step 1: Failing Test `NodeSummaryTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php` anlegen: + +```php + [ + 'content.decoration.bodyFont' => ['presetId' => ['ja6o54jtlc'], 'groupName' => 'divi/font-body'], + 'module.decoration.spacing' => ['presetId' => ['apzj7e17ok', 'b2b2b2b2b2'], 'groupName' => 'divi/spacing'], + ]]); + + self::assertSame( + [ + 'content.decoration.bodyFont' => ['groupName' => 'divi/font-body', 'presetIds' => ['ja6o54jtlc']], + 'module.decoration.spacing' => [ + 'groupName' => 'divi/spacing', + 'presetIds' => ['apzj7e17ok', 'b2b2b2b2b2'], + ], + ], + NodeSummary::groupPresets($block) + ); + } + + public function testPresetIdAsStringIsAccepted(): void + { + $block = Block::create('divi/text', ['groupPreset' => [ + 'content.decoration.bodyFont' => ['presetId' => 'ja6o54jtlc', 'groupName' => 'divi/font-body'], + ]]); + + self::assertSame( + ['content.decoration.bodyFont' => ['groupName' => 'divi/font-body', 'presetIds' => ['ja6o54jtlc']]], + NodeSummary::groupPresets($block) + ); + } + + public function testEmptyIdsAreFilteredAndBrokenEntriesLeftOut(): void + { + $block = Block::create('divi/text', ['groupPreset' => [ + 'a.one' => ['presetId' => ['', 'x1', 7, null], 'groupName' => 'divi/font'], + 'a.two' => ['presetId' => [''], 'groupName' => 'divi/font'], + 'a.three' => ['presetId' => ['x2']], + 'a.four' => ['presetId' => ['x3'], 'groupName' => ''], + 'a.five' => ['presetId' => ['x4'], 'groupName' => ['divi/font']], + 'a.six' => 'kein Eintrag', + ]]); + + self::assertSame( + ['a.one' => ['groupName' => 'divi/font', 'presetIds' => ['x1']]], + NodeSummary::groupPresets($block) + ); + } + + public function testBlockWithoutOrWithBrokenGroupPreset(): void + { + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text'))); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => 'x']))); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => []]))); + } +} +``` + +- [ ] **Step 2: RED** + +Run: `composer test -- --filter NodeSummaryTest` +Expected: FAIL, vier Fehler `Error: Call to undefined method LIT\MCP\Integrations\Divi\BlockTree\NodeSummary::groupPresets()`. + +- [ ] **Step 3: `NodeSummary::groupPresets()` implementieren** + +In `src/Integrations/Divi/BlockTree/NodeSummary.php`: `presetIds()` nutzt die neue private Hilfe `idList()` (gleiches Verhalten: String → `[String]`, leere und Nicht-Strings fallen weg), darunter kommt `groupPresets()`, und `idList()` steht vor `rank()`: + +```diff +--- a/src/Integrations/Divi/BlockTree/NodeSummary.php ++++ b/src/Integrations/Divi/BlockTree/NodeSummary.php +@@ -84,14 +84,37 @@ final class NodeSummary + /** @return list */ + public static function presetIds(Block $block): array + { +- $presets = $block->attrs()['modulePreset'] ?? []; +- if (is_string($presets)) { +- $presets = [$presets]; ++ return self::idList($block->attrs()['modulePreset'] ?? []); ++ } ++ ++ /** ++ * Option group presets of the block (formats §3 "Verweise in Blöcken"): groupPreset. holds ++ * {presetId: [id, …], groupName: "divi/"}. Empty IDs are dropped; an entry without a valid ++ * groupName or without any ID is left out. ++ * ++ * @return array}> groupId => entry ++ */ ++ public static function groupPresets(Block $block): array ++ { ++ $entries = $block->attrs()['groupPreset'] ?? []; ++ if (! is_array($entries)) { ++ return []; + } + +- return is_array($presets) +- ? array_values(array_filter($presets, static fn (mixed $id): bool => is_string($id) && $id !== '')) +- : []; ++ $groups = []; ++ foreach ($entries as $groupId => $entry) { ++ if (! is_array($entry)) { ++ continue; ++ } ++ ++ $name = $entry['groupName'] ?? null; ++ $ids = self::idList($entry['presetId'] ?? []); ++ if (is_string($name) && $name !== '' && $ids !== []) { ++ $groups[(string) $groupId] = ['groupName' => $name, 'presetIds' => $ids]; ++ } ++ } ++ ++ return $groups; + } + + /** Layout facts: section type, column widths of a row (e.g. "8_24+16_24"). */ +@@ -162,6 +185,22 @@ final class NodeSummary + return trim((string) preg_replace('/\s+/u', ' ', $text)); + } + ++ /** ++ * Preset IDs as Divi stores them: a list, or a single string (legacy). ++ * ++ * @return list ++ */ ++ private static function idList(mixed $ids): array ++ { ++ if (is_string($ids)) { ++ $ids = [$ids]; ++ } ++ ++ return is_array($ids) ++ ? array_values(array_filter($ids, static fn (mixed $id): bool => is_string($id) && $id !== '')) ++ : []; ++ } ++ + private static function rank(string $path): int + { + $index = array_search(explode('.', $path)[0], self::PRIORITY, true); +``` + +- [ ] **Step 4: GREEN** + +Run: `composer test -- --filter "NodeSummaryTest|OutlineTest"` +Expected: PASS (`NodeSummaryTest` 4 Tests, `OutlineTest` unverändert grün – `presetIds()` verhält sich wie vorher). + +- [ ] **Step 5: Outline – Tests anpassen und ergänzen (RED)** + +In `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php` den `Document`-Import ergänzen, in `testRowsAndTextOfAPage` den Erwartungswert ändern (alt → neu siehe oben) und zwei Tests vor `testGlobalInstancesShowTheirLibraryIdAndRootType` einfügen. Die Fixture `page--designsystem.html` hat unter `s0.r0.c0` den Text `m2` mit Gruppen-Preset `ja6o54jtlc` (`divi/font-body`) und das Blurb `m5` mit Modul-Preset `vdcl6msnuo` plus Gruppen-Preset `apzj7e17ok` (`divi/spacing`): + +```diff +--- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php ++++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; + + use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; + use LIT\MCP\Integrations\Divi\BlockTree\Block; ++use LIT\MCP\Integrations\Divi\BlockTree\Document; + use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; + use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; + use LIT\MCP\Integrations\Divi\BlockTree\Outline; +@@ -26,7 +27,8 @@ final class OutlineTest extends TestCase + + self::assertSame( + ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', +- 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3, ++ 'info' => '', 'presets' => ['Hero-Titel'], 'group_presets' => [], 'children' => 0, ++ 'editable' => true, 'depth' => 3, + 'truncated' => false, 'global_id' => null], + $rows[5] + ); +@@ -41,6 +43,56 @@ final class OutlineTest extends TestCase + ); + } + ++ public function testGroupPresetsAreShownWithNamesInRowAndText(): void ++ { ++ $content = DiviFixtures::markup('stage4/page--designsystem.html'); ++ $outline = new Outline([ ++ 'vdcl6msnuo' => '[Fixture] Blurb Leistung', ++ 'ja6o54jtlc' => '[Fixture] Fließtext', ++ 'apzj7e17ok' => '[Fixture] Abstand Karte', ++ ]); ++ ++ $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); ++ $byId = array_column($rows, null, 'id'); ++ ++ self::assertSame([], $byId['s0.r0.c0.m1']['group_presets']); ++ self::assertSame(['font-body: [Fixture] Fließtext'], $byId['s0.r0.c0.m2']['group_presets']); ++ self::assertSame(['spacing: [Fixture] Abstand Karte'], $byId['s0.r0.c0.m5']['group_presets']); ++ self::assertSame(['[Fixture] Blurb Leistung'], $byId['s0.r0.c0.m5']['presets']); ++ ++ $lines = explode("\n", $outline->text($rows)); ++ self::assertStringContainsString( ++ 's0.r0.c0.m2 text "Text mit Gruppen-Preset" {Gruppen-Presets: font-body: [Fixture] Fließtext} – ', ++ $lines[5] ++ ); ++ self::assertStringContainsString( ++ 's0.r0.c0.m5 blurb "Blurb mit Modul- und Gruppen-Preset" {Preset: [Fixture] Blurb Leistung} ' ++ . '{Gruppen-Presets: spacing: [Fixture] Abstand Karte} – Möbel nach Maß', ++ $lines[8] ++ ); ++ self::assertStringNotContainsString('Gruppen-Presets', $lines[3]); ++ } ++ ++ public function testUnknownGroupPresetFallsBackToTheIdAndStacksAreListed(): void ++ { ++ $block = Block::create('divi/blurb', ['groupPreset' => [ ++ 'module.decoration.spacing' => ['presetId' => ['a1', 'b2'], 'groupName' => 'divi/spacing'], ++ 'title.decoration.font' => ['presetId' => ['c3'], 'groupName' => 'divi/font'], ++ ]]); ++ $document = new Document(); ++ $document->append($block); ++ $index = NodeIndex::build($document); ++ $outline = new Outline(['a1' => 'Abstand Karte']); ++ ++ $rows = $outline->rows($index); ++ ++ self::assertSame(['spacing: Abstand Karte, b2', 'font: c3'], $rows[0]['group_presets']); ++ self::assertStringContainsString( ++ '{Gruppen-Presets: spacing: Abstand Karte, b2; font: c3}', ++ $outline->text($rows) ++ ); ++ } ++ + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); +``` + +Run: `composer test -- --filter OutlineTest` +Expected: FAIL – `testRowsAndTextOfAPage`: `Failed asserting that two arrays are identical` (das Feld `group_presets` fehlt in der Zeile); die beiden neuen Tests: `Undefined array key "group_presets"`. + +- [ ] **Step 6: Outline implementieren** + +In `src/Integrations/Divi/BlockTree/Outline.php`: Docblocks der Zeile um `group_presets` erweitern, `text()` hängt `{Gruppen-Presets: …}` hinter `{Preset: …}` (Einträge mit `; ` getrennt, weil ein Eintrag selbst Kommas enthalten kann), `row()` füllt das Feld; zwei private Hilfen (`groupPresetLines()`, `namesOf()` – letztere ersetzt die bisherige Inline-Namensauflösung der `presets`): + +```diff +--- a/src/Integrations/Divi/BlockTree/Outline.php ++++ b/src/Integrations/Divi/BlockTree/Outline.php +@@ -62,8 +62,8 @@ final class Outline + + /** + * @return list, children: int, editable: bool, depth: int, truncated: bool, +- * global_id: ?string}> ++ * presets: list, group_presets: list, children: int, editable: bool, depth: int, ++ * truncated: bool, global_id: ?string}> + */ + public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array + { +@@ -121,6 +121,8 @@ final class Outline + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; + $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; + $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; ++ $groupPresets = $row['group_presets'] ?? []; ++ $line .= $groupPresets !== [] ? ' {Gruppen-Presets: ' . implode('; ', $groupPresets) . '}' : ''; + $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; + $line .= $row['editable'] ? '' : ' (nur lesbar)'; + $line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : ''; +@@ -132,8 +134,8 @@ final class Outline + + /** + * @return array{id: string, type: string, label: string, excerpt: string, info: string, +- * presets: list, children: int, editable: bool, depth: int, truncated: bool, +- * global_id: ?string} ++ * presets: list, group_presets: list, children: int, editable: bool, depth: int, ++ * truncated: bool, global_id: ?string} + */ + private function row(Block $block, string $id, int $depth): array + { +@@ -152,10 +154,8 @@ final class Outline + 'label' => NodeSummary::label($block), + 'excerpt' => NodeSummary::excerpt($block), + 'info' => $editable ? NodeSummary::info($block) : '', +- 'presets' => array_map( +- fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, +- NodeSummary::presetIds($block) +- ), ++ 'presets' => $this->namesOf(NodeSummary::presetIds($block)), ++ 'group_presets' => $this->groupPresetLines($block), + 'children' => $editable ? count($block->children()) : 0, + 'editable' => $editable, + 'depth' => $depth, +@@ -163,4 +163,31 @@ final class Outline + 'global_id' => $this->globalIds ? ($global['id'] ?? null) : null, + ]; + } ++ ++ /** ++ * One entry per option group: ": [, ]", e.g. ++ * "font-body: Fließtext" (the short name is the key "group_presets" of the write tools takes). ++ * ++ * @return list ++ */ ++ private function groupPresetLines(Block $block): array ++ { ++ $lines = []; ++ foreach (NodeSummary::groupPresets($block) as $group) { ++ $name = $group['groupName']; ++ $short = str_starts_with($name, 'divi/') ? substr($name, 5) : $name; ++ $lines[] = $short . ': ' . implode(', ', $this->namesOf($group['presetIds'])); ++ } ++ ++ return $lines; ++ } ++ ++ /** ++ * @param list $presetIds ++ * @return list The preset names, the ID where the name is unknown. ++ */ ++ private function namesOf(array $presetIds): array ++ { ++ return array_map(fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, $presetIds); ++ } + } +``` + +Run: `composer test -- --filter OutlineTest` +Expected: PASS (13 Tests). + +- [ ] **Step 7: Abilities – Tests schreiben (RED)** + +In `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` `use ...\Design\DesignSystem;` ergänzen, die Hilfsmethode `designWithGroupPresets()` (liest die echte Fixture `stage4/presets.json` als Divi-Bestand) und zwei Tests vor `testOutlineListsNodesWithTreeRevAndPostRev` einfügen: + +```diff +--- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php ++++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +@@ -4,6 +4,7 @@ declare(strict_types=1); + + namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; + ++use LIT\MCP\Integrations\Divi\Design\DesignSystem; + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; + use LIT\MCP\Integrations\Divi\Library\LibraryAccess; + use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; +@@ -25,6 +26,56 @@ final class DiviReadAbilitiesTest extends TestCase + return $reader; + } + ++ /** The stage 4 preset inventory (tests/Fixtures/divi/stage4/presets.json) as the design system reads it. */ ++ private function designWithGroupPresets(): DesignSystemReader ++ { ++ $stored = json_decode(DiviFixtures::markup('stage4/presets.json'), true)['stored']; ++ $reader = Mockery::mock(DesignSystemReader::class); ++ $reader->allows('read')->andReturn(DesignSystem::fromDivi([], [], $stored, [])); ++ ++ return $reader; ++ } ++ ++ public function testOutlineShowsGroupPresetsWithNames(): void ++ { ++ DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); ++ $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); ++ ++ $result = $ability->execute(['post_id' => 7]); ++ $byId = array_column($result['nodes'], null, 'id'); ++ ++ self::assertSame([], $byId['s0.r0.c0.m1']['group_presets']); ++ self::assertSame(['[Fixture] Text Hinweis'], $byId['s0.r0.c0.m1']['presets']); ++ self::assertSame(['font-body: [Fixture] Fließtext'], $byId['s0.r0.c0.m2']['group_presets']); ++ self::assertSame(['spacing: [Fixture] Abstand Karte'], $byId['s0.r0.c0.m5']['group_presets']); ++ self::assertStringContainsString( ++ '{Preset: [Fixture] Blurb Leistung} {Gruppen-Presets: spacing: [Fixture] Abstand Karte}', ++ $result['outline'] ++ ); ++ self::assertContains('group_presets', $ability->outputSchema()['properties']['nodes']['items']['required']); ++ } ++ ++ public function testNodeGetListsGroupPresetsWithGroupAndNames(): void ++ { ++ DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); ++ $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); ++ ++ $blurb = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m5']); ++ $plain = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m3']); ++ ++ self::assertSame( ++ [[ ++ 'group_id' => 'module.decoration.spacing', ++ 'group' => 'spacing', ++ 'presets' => [['id' => 'apzj7e17ok', 'name' => '[Fixture] Abstand Karte']], ++ ]], ++ $blurb['group_presets'] ++ ); ++ self::assertSame([['id' => 'vdcl6msnuo', 'name' => '[Fixture] Blurb Leistung']], $blurb['presets']); ++ self::assertSame([], $plain['group_presets']); ++ self::assertArrayNotHasKey('groupPreset', $blurb['attrs']); ++ } ++ + public function testOutlineListsNodesWithTreeRevAndPostRev(): void + { + $content = DiviFixtures::markup('synthetic-basic.html'); +``` + +Run: `composer test -- --filter DiviReadAbilitiesTest` +Expected: FAIL – `testNodeGetListsGroupPresetsWithGroupAndNames`: `Undefined array key "group_presets"`; `testOutlineShowsGroupPresetsWithNames`: `Failed asserting that an array contains 'group_presets'` (Ausgabeschema; die Zeilen selbst liefern das Feld seit Schritt 6 schon). + +- [ ] **Step 8: Abilities implementieren** + +`lit-divi-outline` – Beschreibung nennt Gruppen-Presets, Ausgabeschema bekommt `group_presets` (Pflichtfeld, nach `presets`): + +```diff +--- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php ++++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php +@@ -38,7 +38,7 @@ final class DiviOutlineAbility implements Ability + public function description(): string + { + return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, ' +- . 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' ++ . 'Admin-Label, Textauszug, Preset und Gruppen-Presets. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' + . 'Optional nur ein Teilbaum (id), begrenzte Tiefe (depth) oder begrenzte Breite je Ebene (limit); ' + . 'abgeschnittene Kinder zeigt "truncated".'; + } +@@ -91,6 +91,12 @@ final class DiviOutlineAbility implements Ability + 'excerpt' => ['type' => 'string'], + 'info' => ['type' => 'string'], + 'presets' => ['type' => 'array', 'items' => ['type' => 'string']], ++ 'group_presets' => [ ++ 'type' => 'array', ++ 'items' => ['type' => 'string'], ++ 'description' => 'Gruppen-Presets des Moduls als ": [, ]", ' ++ . 'z. B. "font-body: Fließtext". Die Gruppe ist der Schlüssel für group_presets.', ++ ], + 'children' => ['type' => 'integer'], + 'editable' => ['type' => 'boolean'], + 'depth' => ['type' => 'integer'], +@@ -104,8 +110,8 @@ final class DiviOutlineAbility implements Ability + ], + ], + 'required' => [ +- 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', +- 'truncated', 'global_id', ++ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'group_presets', 'children', ++ 'editable', 'depth', 'truncated', 'global_id', + ], + ], + ], +``` + +`lit-divi-node-get` – Beschreibung, Ausgabeschema (Feld nicht in `required`, wie `presets`), Ausgabe und private Hilfe `groupPresets()`; die Beschreibung ist dabei neu umbrochen, damit keine Zeile über 120 Zeichen liegt: + +```diff +--- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php ++++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +@@ -10,6 +10,7 @@ use LIT\MCP\Abilities\Errors; + use LIT\MCP\Abilities\JsonMap; + use LIT\MCP\Abilities\Rev; + use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; ++use LIT\MCP\Integrations\Divi\BlockTree\Block; + use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; + use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +@@ -42,9 +43,9 @@ final class DiviNodeGetAbility implements Ability + { + return 'Liest einen Knoten der Divi-Seite vollständig: attrs als Attributpfad → Werte je Breakpoint ' + . '(Vollform, z. B. "title.decoration.font.font": {"desktop": {"value": {…}}}), die Kurzform-Werte, ' +- . 'Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline. Werte wie $variable(…)$ (globale ' +- . 'Farben, Variablen, dynamische Inhalte) werden unverändert angezeigt, beim Schreiben aber erneut ' +- . 'geprüft, nie ungeprüft übernommen.'; ++ . 'Presets, Gruppen-Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline. Werte wie ' ++ . '$variable(…)$ (globale Farben, Variablen, dynamische Inhalte) werden unverändert angezeigt, beim ' ++ . 'Schreiben aber erneut geprüft, nie ungeprüft übernommen.'; + } + + public function inputSchema(): array +@@ -83,6 +84,27 @@ final class DiviNodeGetAbility implements Ability + 'required' => ['id', 'name'], + ], + ], ++ 'group_presets' => [ ++ 'type' => 'array', ++ 'description' => 'Gruppen-Presets des Moduls: group_id = Attributpfad der Gruppe, group = ' ++ . 'Kurzname (Schlüssel für group_presets).', ++ 'items' => [ ++ 'type' => 'object', ++ 'properties' => [ ++ 'group_id' => ['type' => 'string'], ++ 'group' => ['type' => 'string'], ++ 'presets' => [ ++ 'type' => 'array', ++ 'items' => [ ++ 'type' => 'object', ++ 'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']], ++ 'required' => ['id', 'name'], ++ ], ++ ], ++ ], ++ 'required' => ['group_id', 'group', 'presets'], ++ ], ++ ], + 'builder_version' => ['type' => ['string', 'null']], + 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'global' => ['type' => ['object', 'null']], +@@ -163,6 +185,7 @@ final class DiviNodeGetAbility implements Ability + static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], + NodeSummary::presetIds($block) + ), ++ 'group_presets' => $this->groupPresets($block, $names), + 'builder_version' => is_string($version) ? $version : null, + 'children' => $editable + ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) +@@ -170,4 +193,26 @@ final class DiviNodeGetAbility implements Ability + 'global' => $global, + ]; + } ++ ++ /** ++ * @param array $names Preset ID => name. ++ * @return list}> ++ */ ++ private function groupPresets(Block $block, array $names): array ++ { ++ $groups = []; ++ foreach (NodeSummary::groupPresets($block) as $groupId => $group) { ++ $name = $group['groupName']; ++ $groups[] = [ ++ 'group_id' => $groupId, ++ 'group' => str_starts_with($name, 'divi/') ? substr($name, 5) : $name, ++ 'presets' => array_map( ++ static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], ++ $group['presetIds'] ++ ), ++ ]; ++ } ++ ++ return $groups; ++ } + } +``` + +- [ ] **Step 9: GREEN, Gesamtlauf, Lint** + +Run: `composer test -- --filter "NodeSummaryTest|OutlineTest|DiviReadAbilitiesTest"` +Expected: PASS. + +Run: `composer test && composer lint` +Expected: alle Tests grün (2462 Tests: 2454 plus vier `NodeSummaryTest`, zwei `OutlineTest`, zwei `DiviReadAbilitiesTest`), phpcs ohne Meldung. + +- [ ] **Step 10: Commit** + +Commit wie in den Global Constraints mit der Nachricht `feat: Outline und Knoten zeigen Gruppen-Presets mit Namen`. Stagen: + +```bash +git add src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviOutlineAbility.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +``` + +--- + +### Task 2: `PresetOverrides` – lokale Werte, die Preset-Werte überdecken (R4, R5) + +**Files:** +- Create: `src/Integrations/Divi/Design/PresetOverrides.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php` + +**Interfaces:** +- Consumes: `AttrMap::flatten()`, `AttrMap::isBreakpointMap()`, `AttrMap::remove()`, `AttrMap::META_KEYS` (vorhanden, `BlockTree/AttrMap.php`; `flatten()` lässt die `META_KEYS` auf oberster Ebene weg, `remove()` räumt leer gewordene Eltern auf, `isBreakpointMap()` prüft gegen `AttrMap::BREAKPOINTS`). Gespeicherte Preset-Einträge im Format von `PresetBook::data()` (Formats §3): Modul-Preset mit `attrs` und optionalen `groupPresets` (`{: {presetId: list, groupName}}`), Gruppen-Preset mit `groupId` und `attrs` unterhalb dieses Pfads. +- Produces (Signaturen aus dem Gerüst, unverändert): + - `final class PresetOverrides { public function __construct(private Closure $item); public static function leaves(array $attrs): array; public function find(array $attrs): array; public function clear(array $attrs): array; public function describe(string $nodeId, array $attrs, array $names): array; }` + - Nur private Methoden ergänzt (`assigned`, `addGroupPresets`, `add`, `lookup`, `moved`, `collect`, `pathOf`, `label`, `idList`, `groupEntries`, `plain`, `canonical`, `normalized`). + +Regeln (Spec §6.5, R4/R5; Divi führt Preset und Block mit `array_replace_recursive` zusammen): +- **Blatt** = ein Wert unter `"|||"`. Der Attributpfad ist ein Pfad, an dem eine Breakpoint-Map liegt (`AttrMap::flatten`), der Breakpoint einer aus `AttrMap::BREAKPOINTS`, der Zustand ein Schlüssel der Zustands-Map (`value`, `hover`, `sticky`, …). Ist der Zustandswert ein Objekt, ergibt jeder Schlüssel ein Blatt mit Unterpfad in Punktschreibweise (`padding.top`); ein skalarer Wert oder eine Liste ist ein Blatt mit leerem Unterpfad (`title.innerContent|desktop|value|`). Leere Objekte und Listen ergeben kein Blatt. `META_KEYS` (`modulePreset`, `groupPreset`, `builderVersion`, …) zählen nie. +- **Verglichen** werden nur Blätter, die lokal und im Preset vorkommen: ein lokales `padding.top` überdeckt kein Preset-`padding.bottom`. **Entscheidung Wertvergleich:** nicht strikt, sondern nach Normalisierung – Skalare als Text (`700` = `"700"`), Objekte nach sortierten Schlüsseln, Listen in Reihenfolge. Grund: der Visual Builder schreibt Strings, ein MCP-Client kann Zahlen schreiben; strikter Vergleich würde Warnungen für Werte erzeugen, die im Ergebnis gleich sind, und `clear_overrides` bei denen unnötig laut machen. Verwechslungen wie `true` gegen `"true"` bleiben bewusst Abweichungen. +- **Gezählt** werden nur ausdrücklich zugewiesene Presets (R4): der `modulePreset`-Stapel (Liste oder alter String), die Einträge von `groupPreset` und die verschachtelten `groupPresets` der zugewiesenen Modul-Presets. Standard-Presets (`""`, `default`, `_initial`, unbekannte IDs) zählen nicht – der `item`-Callback liefert für sie `null`. +- **Umhängen:** Ein Gruppen-Preset speichert seine Attribute unterhalb seiner eigenen `groupId`; zugewiesen wird es unter dem `groupId`-Schlüssel des Blocks (bzw. des verschachtelnden Modul-Presets). Die Blätter werden vom gespeicherten `groupId` auf diesen Schlüssel umgehängt; Blätter außerhalb der `groupId` gehören nicht zur Gruppe und entfallen. Ein Gruppen-Preset ohne `groupId` wird unverändert verwendet. +- **`clear()`** entfernt jedes lokale Blatt, das auf einem Blatt eines zugewiesenen Presets liegt – auch bei gleichem Wert (wirkungslos, R4) – und räumt leere Eltern auf; alles andere (andere Unterpfade, Inhalte, `META_KEYS`) bleibt byte-gleich. +- **`describe()`** liefert je überdecktem Preset eine Zeile (R5): `: lokale Werte überdecken „“: [ und N weitere] – clear_overrides: true entfernt sie.` Pfade stehen als `` bei `desktop`/`value`, sonst ` ([, ])` (`value` entfällt); gleiche Pfade (verschiedene Unterpfade desselben Attributs) zählen einmal. Fehlt der Name in `$names`, steht die ID. + +- [ ] **Step 1: Failing Test schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php` anlegen. Die Test-Presets haben Divis Speicherformat: ein Modul-Preset `mmmmmmmmmm` (Blurb Leistung) mit `padding.top/bottom`, `title…font.weight` und einem verschachtelten Gruppen-Preset auf `title.decoration.bodyFont`, ein Gruppen-Preset `ssssssssss` (Abstand Karte, `module.decoration.spacing`) und ein Gruppen-Preset `ffffffffff` (Fließtext, `content.decoration.bodyFont`, mit Hover und Phone): + +```php + ['value' => $value]]; + } + + /** Presets in Divi's stored format (formats §3): a module preset nesting a group preset, a group preset. */ + private static function overrides(): PresetOverrides + { + $items = [ + 'mmmmmmmmmm' => [ + 'type' => 'module', 'id' => 'mmmmmmmmmm', 'name' => 'Blurb Leistung', 'moduleName' => 'divi/blurb', + 'attrs' => [ + 'module' => ['decoration' => ['spacing' => self::desktop([ + 'padding' => ['top' => '20px', 'bottom' => '10px'], + ])]], + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => '700'])]]], + ], + 'groupPresets' => [ + 'title.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body'], + ], + ], + 'ssssssssss' => [ + 'type' => 'group', 'id' => 'ssssssssss', 'name' => 'Abstand Karte', 'groupName' => 'divi/spacing', + 'groupId' => self::SPACING, 'moduleName' => 'divi/blurb', + 'attrs' => ['module' => ['decoration' => ['spacing' => self::desktop([ + 'margin' => ['bottom' => '24px'], + ])]]], + ], + 'ffffffffff' => [ + 'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Fließtext', 'groupName' => 'divi/font-body', + 'groupId' => self::BODY_FONT, 'moduleName' => 'divi/text', + 'attrs' => ['content' => ['decoration' => ['bodyFont' => ['desktop' => [ + 'value' => ['size' => '18px'], + 'hover' => ['size' => '20px'], + ], 'phone' => ['value' => ['size' => '16px']]]]]], + ], + ]; + + return new PresetOverrides(static fn (string $id): ?array => $items[$id] ?? null); + } + + public function testLeavesFlattenBreakpointsStatesAndSubpaths(): void + { + $leaves = PresetOverrides::leaves([ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'groupPreset' => [self::SPACING => ['presetId' => ['ssssssssss'], 'groupName' => 'divi/spacing']], + 'module' => ['decoration' => ['spacing' => [ + 'desktop' => ['value' => ['padding' => ['top' => '20px'], 'syncVertical' => 'on'], 'hover' => [ + 'margin' => ['bottom' => '4px'], + ]], + 'phone' => ['value' => ['padding' => ['top' => '8px']]], + ]]], + 'title' => ['innerContent' => self::desktop('Hallo'), 'decoration' => ['font' => ['font' => self::desktop( + new stdClass() + )]]], + 'content' => ['decoration' => ['bodyFont' => self::desktop(['families' => ['Lato', 'Arial']])]], + ]); + + self::assertSame( + [ + 'content.decoration.bodyFont|desktop|value|families' => ['Lato', 'Arial'], + 'module.decoration.spacing|desktop|value|padding.top' => '20px', + 'module.decoration.spacing|desktop|value|syncVertical' => 'on', + 'module.decoration.spacing|desktop|hover|margin.bottom' => '4px', + 'module.decoration.spacing|phone|value|padding.top' => '8px', + 'title.innerContent|desktop|value|' => 'Hallo', + ], + $leaves + ); + } + + public function testFindReportsOnlyDifferingValuesOfTheModulePreset(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px', 'bottom' => '10px']])]], + // 700 as a number is the same value as "700" (the builder writes strings, an API client may not). + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => 700])]]], + ]; + + self::assertSame( + ['mmmmmmmmmm' => ['module.decoration.spacing|desktop|value|padding.top']], + self::overrides()->find($attrs) + ); + } + + public function testNoOverlapNoReport(): void + { + // A local padding.left does not cover the preset's padding.top/bottom (array_replace_recursive). + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['left' => '5px']])]], + 'title' => ['innerContent' => self::desktop('Text')], + ]; + + self::assertSame([], self::overrides()->find($attrs)); + self::assertSame([], self::overrides()->find([])); + } + + public function testGroupPresetLeavesMoveFromTheStoredGroupIdToTheBlocksKey(): void + { + // The preset was made for content.decoration.bodyFont; this block uses it on text.decoration.bodyFont. + $attrs = [ + 'groupPreset' => ['text.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body']], + 'text' => ['decoration' => ['bodyFont' => [ + 'desktop' => ['value' => ['size' => '22px'], 'hover' => ['size' => '20px']], + 'phone' => ['value' => ['size' => '12px']], + ]]], + // Same path as the preset's stored groupId, but not the block's key: no overlap. + 'content' => ['decoration' => ['bodyFont' => self::desktop(['size' => '99px'])]], + ]; + + self::assertSame( + ['ffffffffff' => [ + 'text.decoration.bodyFont|desktop|value|size', + 'text.decoration.bodyFont|phone|value|size', + ]], + self::overrides()->find($attrs) + ); + } + + public function testNestedGroupPresetsOfAnAssignedModulePresetCount(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'title' => ['decoration' => ['bodyFont' => self::desktop(['size' => '30px'])]], + ]; + + // The nested entry sits on title.decoration.bodyFont of the module preset; its leaves move there. + self::assertSame( + ['ffffffffff' => ['title.decoration.bodyFont|desktop|value|size']], + self::overrides()->find($attrs) + ); + } + + public function testPresetsNobodyAssignedAreNotCounted(): void + { + // Default presets ("", "default", "_initial") and unknown IDs are no explicit assignment (ruling R4). + $attrs = [ + 'modulePreset' => ['', 'default', '_initial', 'unbekannt'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px']])]], + ]; + + self::assertSame([], self::overrides()->find($attrs)); + self::assertSame($attrs, self::overrides()->clear($attrs)); + } + + public function testStackAndSingleStringAreRead(): void + { + $attrs = [ + 'modulePreset' => 'mmmmmmmmmm', + 'groupPreset' => [self::SPACING => ['presetId' => 'ssssssssss', 'groupName' => 'divi/spacing']], + 'module' => ['decoration' => ['spacing' => self::desktop([ + 'padding' => ['bottom' => '11px'], 'margin' => ['bottom' => '1px'], + ])]], + ]; + + self::assertSame( + [ + 'mmmmmmmmmm' => ['module.decoration.spacing|desktop|value|padding.bottom'], + 'ssssssssss' => ['module.decoration.spacing|desktop|value|margin.bottom'], + ], + self::overrides()->find($attrs) + ); + } + + public function testClearRemovesLocalLeavesOnPresetLeavesAndEmptyParents(): void + { + $attrs = [ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop([ + // padding.top is a preset leaf (different value), padding.bottom too (same value), left is not. + 'padding' => ['top' => '40px', 'bottom' => '10px', 'left' => '5px'], + ])]], + 'title' => [ + 'innerContent' => self::desktop('Hallo'), + 'decoration' => ['font' => ['font' => self::desktop(['weight' => '400'])]], + ], + ]; + + self::assertSame( + [ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['left' => '5px']])]], + 'title' => ['innerContent' => self::desktop('Hallo')], + ], + self::overrides()->clear($attrs) + ); + } + + public function testClearDropsAWholeBranchWhenNothingElseIsLeft(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => '400'])]]], + ]; + + self::assertSame(['modulePreset' => ['mmmmmmmmmm']], self::overrides()->clear($attrs)); + } + + public function testDescribeFollowsRulingR5(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'groupPreset' => ['text.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body']], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px']])]], + 'text' => ['decoration' => ['bodyFont' => [ + 'desktop' => ['hover' => ['size' => '1px'], 'value' => ['size' => '2px']], + 'phone' => ['value' => ['size' => '3px']], + ]]], + ]; + $names = ['mmmmmmmmmm' => 'Blurb Leistung', 'ffffffffff' => 'Fließtext']; + + self::assertSame( + [ + 's0.r0.c0.m2: lokale Werte überdecken „Blurb Leistung“: module.decoration.spacing ' + . '– clear_overrides: true entfernt sie.', + 's0.r0.c0.m2: lokale Werte überdecken „Fließtext“: text.decoration.bodyFont, ' + . 'text.decoration.bodyFont (desktop, hover), text.decoration.bodyFont (phone) ' + . '– clear_overrides: true entfernt sie.', + ], + self::overrides()->describe('s0.r0.c0.m2', $attrs, $names) + ); + } + + public function testDescribeShowsAtMostFivePathsAndCountsTheRest(): void + { + $preset = ['id' => 'pppppppppp', 'name' => 'Viele', 'attrs' => []]; + $local = ['modulePreset' => ['pppppppppp']]; + foreach (range(1, 7) as $number) { + $preset['attrs']['module']['decoration']['p' . $number] = self::desktop('a'); + $local['module']['decoration']['p' . $number] = self::desktop('b'); + } + $overrides = new PresetOverrides(static fn (string $id): ?array => $id === 'pppppppppp' ? $preset : null); + + self::assertSame( + ['x1: lokale Werte überdecken „pppppppppp“: module.decoration.p1, module.decoration.p2, ' + . 'module.decoration.p3, module.decoration.p4, module.decoration.p5 und 2 weitere ' + . '– clear_overrides: true entfernt sie.'], + $overrides->describe('x1', $local, []) + ); + } + + public function testDescribeIsEmptyWithoutOverrides(): void + { + self::assertSame([], self::overrides()->describe('s0', ['modulePreset' => ['mmmmmmmmmm']], [])); + } +} +``` + +- [ ] **Step 2: RED** + +Run: `composer test -- --filter PresetOverridesTest` +Expected: FAIL, 12 Fehler `Error: Class "LIT\MCP\Integrations\Divi\Design\PresetOverrides" not found`. + +- [ ] **Step 3: `PresetOverrides` implementieren** + +Datei `src/Integrations/Divi/Design/PresetOverrides.php`: + +```php +|||" (the sub path is empty for a scalar state value, e.g. the text of + * "title.innerContent"). A local padding.top covers a preset's padding.top, never its padding.bottom. + * + * Counted are the presets of the block's modulePreset stack, the entries of its groupPreset, and the groupPresets + * nested in the assigned module presets – not the default presets Divi applies implicitly. A group preset stores its + * attributes below its own groupId; they are moved to the key the block (or the nesting module preset) uses for it. + * Two values are the same when their scalars are the same text (Divi's builder writes "700", an API client may write + * 700), lists and objects compare by content. Pure PHP; the preset data enters through the closure. + */ +final class PresetOverrides +{ + private const MAX_PATHS = 5; + + /** @param Closure(string $id): ?array $item Stored preset item (module or group) or null. */ + public function __construct(private Closure $item) + { + } + + /** + * @param array $attrs + * @return array "path|breakpoint|state|subpath" => value; AttrMap::META_KEYS are left out. + */ + public static function leaves(array $attrs): array + { + $leaves = []; + foreach (AttrMap::flatten($attrs) as $path => $map) { + if (! AttrMap::isBreakpointMap($map)) { + continue; + } + + foreach ($map as $breakpoint => $states) { + $states = self::plain($states); + foreach (is_array($states) ? $states : [] as $state => $value) { + self::collect($path . '|' . $breakpoint . '|' . $state, $value, '', $leaves); + } + } + } + + return $leaves; + } + + /** + * @param array $attrs Attributes of a block. + * @return array> Preset ID => local leaves with another value than the preset's. + */ + public function find(array $attrs): array + { + $local = self::leaves($attrs); + $found = []; + foreach ($this->assigned($attrs) as $id => $leaves) { + foreach ($leaves as $key => $value) { + if (array_key_exists($key, $local) && self::canonical($local[$key]) !== self::canonical($value)) { + $found[$id][] = $key; + } + } + } + + return $found; + } + + /** + * Removes every local leaf that lies on a leaf of an assigned preset – also one with the same value, which + * changes nothing – and the parents that become empty. + * + * @param array $attrs + * @return array + */ + public function clear(array $attrs): array + { + $covered = []; + foreach ($this->assigned($attrs) as $leaves) { + $covered += $leaves; + } + + foreach (array_keys(self::leaves($attrs)) as $key) { + if (array_key_exists($key, $covered)) { + $attrs = AttrMap::remove($attrs, self::pathOf($key)); + } + } + + return $attrs; + } + + /** + * One warning per overridden preset (ruling R5). + * + * @param array $attrs + * @param array $names Preset ID => name. + * @return list + */ + public function describe(string $nodeId, array $attrs, array $names): array + { + $lines = []; + foreach ($this->find($attrs) as $id => $keys) { + $paths = array_values(array_unique(array_map([self::class, 'label'], $keys))); + $shown = implode(', ', array_slice($paths, 0, self::MAX_PATHS)); + $more = count($paths) - self::MAX_PATHS; + $lines[] = sprintf( + '%s: lokale Werte überdecken „%s“: %s%s – clear_overrides: true entfernt sie.', + $nodeId, + $names[$id] ?? $id, + $shown, + $more > 0 ? sprintf(' und %d weitere', $more) : '' + ); + } + + return $lines; + } + + /** + * Leaves of the explicitly assigned presets, in assignment order: each module preset of the stack followed by the + * group presets nested in it, then the block's own group presets. + * + * @param array $attrs + * @return array> Preset ID => leaves (keys as in leaves()). + */ + private function assigned(array $attrs): array + { + $assigned = []; + foreach (self::idList($attrs['modulePreset'] ?? null) as $id) { + $item = $this->lookup($id); + if ($item === null) { + continue; + } + + $this->add($assigned, $id, $item, null); + foreach (self::groupEntries($item['groupPresets'] ?? null) as $target => $ids) { + $this->addGroupPresets($assigned, $target, $ids); + } + } + + foreach (self::groupEntries($attrs['groupPreset'] ?? null) as $target => $ids) { + $this->addGroupPresets($assigned, $target, $ids); + } + + return $assigned; + } + + /** + * @param array> $assigned + * @param list $ids + */ + private function addGroupPresets(array &$assigned, string $target, array $ids): void + { + foreach ($ids as $id) { + $item = $this->lookup($id); + if ($item !== null) { + $this->add($assigned, $id, $item, $target); + } + } + } + + /** + * @param array> $assigned + * @param array $item + */ + private function add(array &$assigned, string $id, array $item, ?string $target): void + { + $attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : []; + $leaves = self::leaves($attrs); + $from = $item['groupId'] ?? null; + if ($target !== null && is_string($from) && $from !== $target) { + $leaves = self::moved($leaves, $from, $target); + } + + $assigned[$id] = ($assigned[$id] ?? []) + $leaves; + } + + /** @return array|null */ + private function lookup(string $id): ?array + { + $item = ($this->item)($id); + + return is_array($item) ? $item : null; + } + + /** + * Leaves below the attribute path $from, moved below $to; leaves outside $from do not belong to the group. + * + * @param array $leaves + * @return array + */ + private static function moved(array $leaves, string $from, string $to): array + { + $moved = []; + foreach ($leaves as $key => $value) { + [$path, $rest] = explode('|', $key, 2); + if ($path === $from) { + $moved[$to . '|' . $rest] = $value; + } elseif (str_starts_with($path, $from . '.')) { + $moved[$to . substr($path, strlen($from)) . '|' . $rest] = $value; + } + } + + return $moved; + } + + /** + * @param array $leaves + */ + private static function collect(string $head, mixed $value, string $sub, array &$leaves): void + { + $value = self::plain($value); + if (is_array($value) && ! array_is_list($value)) { + foreach ($value as $key => $inner) { + self::collect($head, $inner, $sub === '' ? (string) $key : $sub . '.' . $key, $leaves); + } + + return; + } + + if ($value !== []) { + $leaves[$head . '|' . $sub] = $value; + } + } + + private static function pathOf(string $key): string + { + [$path, $breakpoint, $state, $sub] = explode('|', $key, 4); + + return $path . '.' . $breakpoint . '.' . $state . ($sub === '' ? '' : '.' . $sub); + } + + /** "" for desktop/value, else " ([, ])" (ruling R5). */ + private static function label(string $key): string + { + [$path, $breakpoint, $state] = explode('|', $key, 4); + if ($breakpoint === 'desktop' && $state === 'value') { + return $path; + } + + return $path . ' (' . $breakpoint . ($state === 'value' ? '' : ', ' . $state) . ')'; + } + + /** @return list */ + private static function idList(mixed $ids): array + { + if (is_string($ids)) { + $ids = [$ids]; + } + + return is_array($ids) + ? array_values(array_filter($ids, static fn (mixed $id): bool => is_string($id) && $id !== '')) + : []; + } + + /** + * @return array> Attribute path of the group => preset IDs. + */ + private static function groupEntries(mixed $entries): array + { + $entries = self::plain($entries); + $result = []; + foreach (is_array($entries) ? $entries : [] as $groupId => $entry) { + $ids = is_array($entry) ? self::idList($entry['presetId'] ?? null) : []; + if ($ids !== []) { + $result[(string) $groupId] = $ids; + } + } + + return $result; + } + + private static function plain(mixed $value): mixed + { + return $value instanceof stdClass ? get_object_vars($value) : $value; + } + + /** A form two equal values share: scalars as text, objects by sorted keys, lists in order. */ + private static function canonical(mixed $value): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + return (string) json_encode(self::normalized($value), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } + + private static function normalized(mixed $value): mixed + { + $value = self::plain($value); + if (! is_array($value)) { + return is_scalar($value) ? (string) $value : ''; + } + + $value = array_map([self::class, 'normalized'], $value); + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } +} +``` + +- [ ] **Step 4: GREEN** + +Run: `composer test -- --filter PresetOverridesTest` +Expected: PASS (12 Tests). + +- [ ] **Step 5: Gesamtlauf, Lint** + +Run: `composer test && composer lint` +Expected: alle Tests grün (2474 Tests), phpcs ohne Meldung. + +- [ ] **Step 6: Commit** + +Commit wie in den Global Constraints mit der Nachricht `feat: PresetOverrides – lokale Werte finden, beschreiben und entfernen, die Presets überdecken`. Stagen: + +```bash +git add src/Integrations/Divi/Design/PresetOverrides.php tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php +``` + +--- + +### Task 3: `GroupKeys` und `PresetAssignment` (rein); `PresetComposer` nutzt `GroupKeys` (R2, R3) + +**Files:** +- Create: `src/Integrations/Divi/Design/GroupKeys.php` +- Create: `src/Integrations/Divi/Design/PresetAssignment.php` +- Modify: `src/Integrations/Divi/Design/PresetComposer.php` (private `findGroup()` entfällt, `groupPresets()` nutzt `GroupKeys`) +- Test: `tests/Unit/Integrations/Divi/Design/GroupKeysTest.php` (neu), `tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php` (neu) +- Modify: `tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` (Hilfsmethode `composer()` mit optionalem `$groupsOf`, ein neuer Test) + +**Interfaces:** +- Consumes: `PresetBook::find(PresetScope, string): ?array` (wirft `InvalidArgumentException` bei mehrdeutigem Namen, Text `Preset-Name "X" ist mehrdeutig; bitte die ID angeben: a, b.`), `PresetBook::items()`, `PresetBook::data()`, `PresetScope` (alle vorhanden), `PresetOverrides` (Task 2). Aus dem Wiring (Task 4, nicht hier): `$book` = Closure auf den `PresetBook` des Design-Systems, `$groupsOf` = `DesignSystemWriter::groupsOf(...)`, `$validId` = `ValueCleaner`-Prüfung von `modulePreset` wie heute in `Composer::preset()`. +- Produces (Signaturen aus dem Gerüst, unverändert): + - `GroupKeys::resolve(array $groups, string $key): string` – wirft `InvalidArgumentException` mit Code `GroupKeys::UNKNOWN` (Meldung `keine Gruppe ""`, ohne Modul – der Aufrufer ergänzt es) oder `GroupKeys::AMBIGUOUS` (Meldung `Gruppe "" ist mehrdeutig; bitte den Attributpfad angeben: , .`). Ergänzt nur die zwei öffentlichen Konstanten. + - `PresetAssignment::__construct(Closure $book, Closure $groupsOf, Closure $validId)`; `apply(string $module, array $attrs, array $spec, string $path): array{attrs, errors}`; `clearOverrides(array $attrs): array`; `overrides(): PresetOverrides`; `names(): array`. Nur private Methoden ergänzt (`stack`, `groups`, `resolve`, `idOf`, `namesIn`, `shortNames`, `book`, `items`). + +Regeln: +- **`GroupKeys::resolve`**: Zuerst gilt ein Schlüssel, der selbst ein `groupId` (Attributpfad) ist. Sonst Kurzname (`font-body`) oder voller Name (`divi/font-body`) gegen den Gruppennamen; genau ein Treffer → dessen `groupId`; keiner → `UNKNOWN`; mehrere (Modul hat mehrere Gruppen gleichen Namens) → `AMBIGUOUS` mit allen Pfaden in der Reihenfolge von `groupsOf`. +- **`apply` – `preset`** (R2): `null` oder `""` entfernt `modulePreset` (ohne den Bestand zu laden). String oder Liste von 1–10 Strings; jeder Eintrag per ID oder Name (ohne Beachtung der Groß-/Kleinschreibung) über `PresetBook::find()` im Bereich des Moduls; Ergebnis ist immer die Liste der IDs in Reihenfolge (ein String wird `[id]`). Fehler, jeweils mit unverändertem `modulePreset`: Pfad `$path.preset` bei String, Gesamtform und Anzahl; `$path.preset.` bei einem Listeneintrag – unbekannter Name (`unbekanntes Preset; vorhanden für : `, wie heute im Composer), mehrdeutiger Name (Text aus `PresetBook::find`), keine Zeichenkette, ID besteht `$validId` nicht (`Preset-ID "" ist ungültig`, wie heute), doppelte ID nach Auflösung (`Preset "" steht mehrfach im Stapel`). Leere Liste: `leere Liste – null entfernt das Preset`; mehr als 10: `höchstens 10 Presets im Stapel`; Objekt oder Zahl: `Text, Liste von Texten oder null erwartet`. +- **`apply` – `group_presets`**: Objekt `{Schlüssel: Name | ID | Liste | null}`; sonst Fehler `$path.group_presets`: `Objekt Gruppe → Preset erwartet`. Je Schlüssel: `GroupKeys::resolve` über `groupsOf($module)` (unbekannt → `keine Gruppe "" in ; vorhanden: `, mehrdeutig → Text aus `GroupKeys`); `null` entfernt den Eintrag dieser Gruppe, ein dadurch leeres `groupPreset` verschwindet ganz; sonst Auflösung der Preset-Namen im Bereich `group` mit dem Gruppennamen (`PresetScope(GROUP, )`) und Speichern als `groupPreset. = {presetId: [ids], groupName}`, andere Gruppen bleiben. Alle Fehler eines Schlüssels stehen unter `$path.group_presets.` (bei Listen der erste; unbekanntes Gruppen-Preset: `unbekanntes Gruppen-Preset "" ()`, wie heute im `PresetComposer`). +- **Engstelle:** `PresetAssignment` kennt nur `Closure`s und `PresetBook`; Gruppen-Schlüssel und `groupName` kommen aus `groupsOf`, nie aus der Eingabe. Das `PresetBook` wird beim ersten Bedarf geladen und danach gehalten (`apply` ohne `preset`/`group_presets` und `preset: null` laden nichts). +- **`PresetComposer`:** gleiche Meldungen wie bisher (`keine Gruppe "" in `, `unbekanntes Gruppen-Preset "

" ()`). **Einzige Verhaltensänderung:** Teilen zwei Gruppen eines Moduls denselben Namen, wählte die alte private Suche still die erste; jetzt ist das – wie in R3 für alle Schreibwege vorgesehen – ein Fehler mit den Pfaden. Der Pfad (`button.decoration.font`) löst ihn. + +- [ ] **Step 1: Failing Test `GroupKeysTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/GroupKeysTest.php`: + +```php + groupId => groupName, as DesignSystemWriter::groupsOf() returns it. */ + private static function groups(): array + { + return [ + 'title.decoration.font' => 'divi/font', + 'content.decoration.bodyFont' => 'divi/font-body', + 'module.decoration.spacing' => 'divi/spacing', + 'button.decoration.spacing' => 'divi/spacing', + ]; + } + + public function testShortNameFullNameAndPathFindTheGroup(): void + { + self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'font-body')); + self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'divi/font-body')); + self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'content.decoration.bodyFont')); + self::assertSame('title.decoration.font', GroupKeys::resolve(self::groups(), 'font')); + } + + public function testSeveralGroupsWithTheSameNameNeedThePath(): void + { + self::assertSame('button.decoration.spacing', GroupKeys::resolve(self::groups(), 'button.decoration.spacing')); + + foreach (['spacing', 'divi/spacing'] as $key) { + try { + GroupKeys::resolve(self::groups(), $key); + self::fail('Ambiguous group name must throw.'); + } catch (InvalidArgumentException $e) { + self::assertSame(GroupKeys::AMBIGUOUS, $e->getCode()); + self::assertSame( + sprintf( + 'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: ' + . 'module.decoration.spacing, button.decoration.spacing.', + $key + ), + $e->getMessage() + ); + } + } + } + + public function testUnknownGroup(): void + { + try { + GroupKeys::resolve(self::groups(), 'sizing'); + self::fail('Unknown group must throw.'); + } catch (InvalidArgumentException $e) { + self::assertSame(GroupKeys::UNKNOWN, $e->getCode()); + self::assertSame('keine Gruppe "sizing"', $e->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + GroupKeys::resolve([], 'font'); + } +} +``` + +Run: `composer test -- --filter GroupKeysTest` +Expected: FAIL, drei Fehler `Error: Class "LIT\MCP\Integrations\Divi\Design\GroupKeys" not found`. + +- [ ] **Step 2: `GroupKeys` implementieren** + +Datei `src/Integrations/Divi/Design/GroupKeys.php`: + +```php + $groups groupId => groupName, e.g. "module.decoration.spacing" => "divi/spacing" + * @return string The groupId. + * @throws InvalidArgumentException German message; code UNKNOWN or AMBIGUOUS. The UNKNOWN message does not name + * the module – the caller adds it. + */ + public static function resolve(array $groups, string $key): string + { + if (array_key_exists($key, $groups)) { + return (string) $key; + } + + $paths = []; + foreach ($groups as $groupId => $name) { + if ($key === $name || 'divi/' . $key === $name) { + $paths[] = (string) $groupId; + } + } + + if ($paths === []) { + throw new InvalidArgumentException(sprintf('keine Gruppe "%s"', $key), self::UNKNOWN); + } + + if (count($paths) > 1) { + throw new InvalidArgumentException(sprintf( + 'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: %s.', + $key, + implode(', ', $paths) + ), self::AMBIGUOUS); + } + + return $paths[0]; + } +} +``` + +Run: `composer test -- --filter GroupKeysTest` +Expected: PASS (3 Tests). + +- [ ] **Step 3: Failing Test `PresetAssignmentTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php`. Der Bestand ist ein eigener, kleiner Inhalt im Speicherformat (nicht `PresetBookTest::stored()`, dessen Zahlen andere Tests prüfen): `divi/blurb` mit fünf Modul-Presets (zwei mit gleichem Namen `Doppelt`/`doppelt`, eines mit ungültiger ID `ba!d000005`), Gruppen-Presets in `divi/font-body` (zwei gleichnamige `Doppelt`), `divi/spacing` und `divi/font`. `groupsOf('divi/blurb')` liefert zwei Gruppen mit dem Namen `divi/spacing` (Mehrdeutigkeit), für andere Module `[]`; `$validId` akzeptiert nur `[0-9a-z]+`. Der Test zählt, wie oft die Book-Closure läuft: + +```php + [ + 'type' => 'module', 'id' => $id, 'name' => $name, 'moduleName' => 'divi/blurb', 'attrs' => $attrs, + ]; + $group = static fn (string $id, string $name, string $groupName, string $groupId, array $attrs = []): array => [ + 'type' => 'group', 'id' => $id, 'name' => $name, 'groupName' => $groupName, 'groupId' => $groupId, + 'moduleName' => 'divi/blurb', 'attrs' => $attrs, + ]; + + return [ + 'module' => ['divi/blurb' => ['default' => '', 'items' => [ + 'bl00000001' => $module('bl00000001', 'Leistung', [ + 'title' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['weight' => '700']]]]]], + ]), + 'bl00000002' => $module('bl00000002', 'Karte'), + 'bl00000003' => $module('bl00000003', 'Doppelt'), + 'bl00000004' => $module('bl00000004', 'doppelt'), + 'ba!d000005' => $module('ba!d000005', 'Kaputt'), + ]]], + 'group' => [ + 'divi/font-body' => ['default' => '', 'items' => [ + 'fb00000001' => $group('fb00000001', 'Fließtext', 'divi/font-body', 'content.decoration.bodyFont'), + 'fb00000002' => $group('fb00000002', 'Fließtext groß', 'divi/font-body', 'content.decoration.bodyFont'), + 'fb00000003' => $group('fb00000003', 'Doppelt', 'divi/font-body', 'content.decoration.bodyFont'), + 'fb00000004' => $group('fb00000004', 'Doppelt', 'divi/font-body', 'content.decoration.bodyFont'), + ]], + 'divi/spacing' => ['default' => '', 'items' => [ + 'sp00000001' => $group('sp00000001', 'Abstand Karte', 'divi/spacing', 'module.decoration.spacing'), + ]], + 'divi/font' => ['default' => '', 'items' => [ + 'ft00000001' => $group('ft00000001', 'Titelschrift', 'divi/font', 'title.decoration.font'), + ]], + ], + ]; + } + + private function assignment(?Closure $book = null): PresetAssignment + { + return new PresetAssignment( + $book ?? function (): PresetBook { + ++$this->bookCalls; + + return PresetBook::fromDivi(self::stored()); + }, + static fn (string $module): array => $module === 'divi/blurb' ? [ + 'title.decoration.font' => 'divi/font', + 'content.decoration.bodyFont' => 'divi/font-body', + 'module.decoration.spacing' => 'divi/spacing', + 'button.decoration.spacing' => 'divi/spacing', + ] : [], + static fn (string $id): bool => preg_match('/^[0-9a-z]+$/D', $id) === 1 + ); + } + + public function testSingleNameOrIdBecomesAStackOfOne(): void + { + $byName = $this->assignment()->apply('divi/blurb', ['x' => 1], ['preset' => 'LEISTUNG'], self::PATH); + $byId = $this->assignment()->apply('divi/blurb', [], ['preset' => 'bl00000002'], self::PATH); + + self::assertSame([], $byName['errors']); + self::assertSame(['x' => 1, 'modulePreset' => ['bl00000001']], $byName['attrs']); + self::assertSame(['modulePreset' => ['bl00000002']], $byId['attrs']); + } + + public function testListKeepsTheOrderOfTheStack(): void + { + $result = $this->assignment()->apply( + 'divi/blurb', + ['modulePreset' => ['alt']], + ['preset' => ['Karte', 'bl00000001']], + self::PATH + ); + + self::assertSame([], $result['errors']); + self::assertSame(['modulePreset' => ['bl00000002', 'bl00000001']], $result['attrs']); + } + + public function testNullAndEmptyStringRemoveThePresetWithoutLoadingTheBook(): void + { + foreach ([null, ''] as $preset) { + $result = $this->assignment()->apply( + 'divi/blurb', + ['modulePreset' => ['bl00000001'], 'builderVersion' => '5.2.1'], + ['preset' => $preset], + self::PATH + ); + + self::assertSame([], $result['errors']); + self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']); + } + self::assertSame(0, $this->bookCalls); + } + + public function testSpecWithoutPresetKeysChangesNothing(): void + { + $attrs = ['modulePreset' => ['bl00000001']]; + $result = $this->assignment()->apply('divi/blurb', $attrs, ['label' => 'egal'], self::PATH); + + self::assertSame(['attrs' => $attrs, 'errors' => []], $result); + self::assertSame(0, $this->bookCalls); + } + + public function testStackErrorsLeaveTheStackUntouched(): void + { + $attrs = ['modulePreset' => ['alt']]; + $assignment = $this->assignment(); + + $unknown = $assignment->apply('divi/blurb', $attrs, ['preset' => 'Gibt es nicht'], self::PATH); + self::assertSame( + ['items[0].preset' => 'unbekanntes Preset; vorhanden für divi/blurb: Leistung, Karte, Doppelt, doppelt, Kaputt'], + $unknown['errors'] + ); + self::assertSame($attrs, $unknown['attrs']); + + $ambiguous = $assignment->apply('divi/blurb', $attrs, ['preset' => ['Karte', 'Doppelt']], self::PATH); + self::assertSame( + ['items[0].preset.1' => 'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: ' + . 'bl00000003, bl00000004.'], + $ambiguous['errors'] + ); + self::assertSame($attrs, $ambiguous['attrs']); + + $duplicate = $assignment->apply('divi/blurb', $attrs, ['preset' => ['Karte', 'bl00000001', 'bl00000002']], self::PATH); + self::assertSame(['items[0].preset.2' => 'Preset "bl00000002" steht mehrfach im Stapel'], $duplicate['errors']); + + $invalid = $assignment->apply('divi/blurb', $attrs, ['preset' => 'Kaputt'], self::PATH); + self::assertSame(['items[0].preset' => 'Preset-ID "ba!d000005" ist ungültig'], $invalid['errors']); + self::assertSame($attrs, $invalid['attrs']); + } + + public function testStackShapeErrors(): void + { + $assignment = $this->assignment(); + + $empty = $assignment->apply('divi/blurb', [], ['preset' => []], self::PATH); + self::assertSame(['items[0].preset' => 'leere Liste – null entfernt das Preset'], $empty['errors']); + + $tooMany = $assignment->apply('divi/blurb', [], ['preset' => array_fill(0, 11, 'Karte')], self::PATH); + self::assertSame(['items[0].preset' => 'höchstens 10 Presets im Stapel'], $tooMany['errors']); + + $notText = $assignment->apply('divi/blurb', [], ['preset' => ['Karte', 5]], self::PATH); + self::assertSame(['items[0].preset.1' => 'Preset-Name oder -ID als Text erwartet'], $notText['errors']); + + $object = $assignment->apply('divi/blurb', [], ['preset' => ['a' => 'Karte']], self::PATH); + self::assertSame(['items[0].preset' => 'Text, Liste von Texten oder null erwartet'], $object['errors']); + + $number = $assignment->apply('divi/blurb', [], ['preset' => 7], self::PATH); + self::assertSame(['items[0].preset' => 'Text, Liste von Texten oder null erwartet'], $number['errors']); + } + + public function testGroupPresetsByShortNameFullNameAndPath(): void + { + $result = $this->assignment()->apply('divi/blurb', ['builderVersion' => '5.2.1'], ['group_presets' => [ + 'font-body' => 'Fließtext', + 'divi/font' => 'ft00000001', + 'button.decoration.spacing' => ['Abstand Karte'], + ]], self::PATH); + + self::assertSame([], $result['errors']); + self::assertSame( + [ + 'builderVersion' => '5.2.1', + 'groupPreset' => [ + 'content.decoration.bodyFont' => ['presetId' => ['fb00000001'], 'groupName' => 'divi/font-body'], + 'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'], + 'button.decoration.spacing' => ['presetId' => ['sp00000001'], 'groupName' => 'divi/spacing'], + ], + ], + $result['attrs'] + ); + } + + public function testGroupPresetListIsAStackAndOtherGroupsStay(): void + { + $attrs = ['groupPreset' => [ + 'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'], + ]]; + + $result = $this->assignment()->apply( + 'divi/blurb', + $attrs, + ['group_presets' => ['font-body' => ['Fließtext', 'Fließtext groß']]], + self::PATH + ); + + self::assertSame([], $result['errors']); + self::assertSame(['fb00000001', 'fb00000002'], $result['attrs']['groupPreset']['content.decoration.bodyFont']['presetId']); + self::assertSame($attrs['groupPreset']['title.decoration.font'], $result['attrs']['groupPreset']['title.decoration.font']); + } + + public function testNullRemovesTheGroupEntryAndAnEmptyGroupPresetDisappears(): void + { + $attrs = ['groupPreset' => [ + 'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'], + 'content.decoration.bodyFont' => ['presetId' => ['fb00000001'], 'groupName' => 'divi/font-body'], + ]]; + + $one = $this->assignment()->apply('divi/blurb', $attrs, ['group_presets' => ['font' => null]], self::PATH); + self::assertSame(['content.decoration.bodyFont'], array_keys($one['attrs']['groupPreset'])); + + $both = $this->assignment()->apply( + 'divi/blurb', + $attrs, + ['group_presets' => ['font' => null, 'font-body' => null]], + self::PATH + ); + self::assertSame([], $both['errors']); + self::assertSame([], $both['attrs']); + } + + public function testGroupKeyErrors(): void + { + $assignment = $this->assignment(); + + $unknown = $assignment->apply('divi/blurb', [], ['group_presets' => ['sizing' => 'X']], self::PATH); + self::assertSame( + ['items[0].group_presets.sizing' => 'keine Gruppe "sizing" in divi/blurb; vorhanden: font, font-body, spacing'], + $unknown['errors'] + ); + + $ambiguous = $assignment->apply('divi/blurb', [], ['group_presets' => ['spacing' => 'Abstand Karte']], self::PATH); + self::assertSame( + ['items[0].group_presets.spacing' => 'Gruppe "spacing" ist mehrdeutig; bitte den Attributpfad angeben: ' + . 'module.decoration.spacing, button.decoration.spacing.'], + $ambiguous['errors'] + ); + + $missing = $assignment->apply('divi/divider', [], ['group_presets' => ['font-body' => 'Fließtext']], self::PATH); + self::assertSame( + ['items[0].group_presets.font-body' => 'keine Gruppe "font-body" in divi/divider; vorhanden: (keine)'], + $missing['errors'] + ); + } + + public function testGroupPresetValueErrors(): void + { + $assignment = $this->assignment(); + + $result = $assignment->apply('divi/blurb', [], ['group_presets' => [ + 'font-body' => 'Gibt es nicht', + 'font' => 7, + 'module.decoration.spacing' => [], + ]], self::PATH); + self::assertSame( + [ + 'items[0].group_presets.font-body' => 'unbekanntes Gruppen-Preset "Gibt es nicht" (divi/font-body)', + 'items[0].group_presets.font' => 'Text, Liste von Texten oder null erwartet', + 'items[0].group_presets.module.decoration.spacing' => 'leere Liste – null entfernt das Gruppen-Preset', + ], + $result['errors'] + ); + self::assertSame([], $result['attrs']); + + $ambiguous = $assignment->apply('divi/blurb', [], ['group_presets' => ['font-body' => 'Doppelt']], self::PATH); + self::assertSame( + ['items[0].group_presets.font-body' => 'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: ' + . 'fb00000003, fb00000004.'], + $ambiguous['errors'] + ); + + $duplicate = $assignment->apply( + 'divi/blurb', + [], + ['group_presets' => ['font-body' => ['Fließtext', 'fb00000001']]], + self::PATH + ); + self::assertSame( + ['items[0].group_presets.font-body' => 'Preset "fb00000001" steht mehrfach im Stapel'], + $duplicate['errors'] + ); + + $shape = $assignment->apply('divi/blurb', [], ['group_presets' => 'font-body'], self::PATH); + self::assertSame(['items[0].group_presets' => 'Objekt Gruppe → Preset erwartet'], $shape['errors']); + } + + public function testModulePresetFromAnotherModuleIsUnknown(): void + { + $result = $this->assignment()->apply('divi/text', [], ['preset' => 'Leistung'], self::PATH); + + self::assertSame(['items[0].preset' => 'unbekanntes Preset; vorhanden für divi/text: (keine)'], $result['errors']); + } + + public function testBookIsLoadedLazilyAndAtMostOnce(): void + { + $assignment = $this->assignment(); + self::assertSame(0, $this->bookCalls); + + $assignment->apply('divi/blurb', [], ['preset' => 'Karte', 'group_presets' => ['font' => 'Titelschrift']], self::PATH); + $assignment->names(); + $assignment->clearOverrides(['modulePreset' => ['bl00000001']]); + $assignment->overrides()->find(['modulePreset' => ['bl00000001']]); + + self::assertSame(1, $this->bookCalls); + } + + public function testNamesListsEveryPreset(): void + { + $names = $this->assignment()->names(); + + self::assertSame('Leistung', $names['bl00000001']); + self::assertSame('Fließtext groß', $names['fb00000002']); + self::assertSame('Titelschrift', $names['ft00000001']); + self::assertCount(11, $names); + } + + public function testClearOverridesAndOverridesShareTheSameBook(): void + { + $assignment = $this->assignment(); + $local = [ + 'modulePreset' => ['bl00000001'], + 'title' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['weight' => '400']]]]]], + ]; + + self::assertSame( + ['bl00000001' => ['title.decoration.font.font|desktop|value|weight']], + $assignment->overrides()->find($local) + ); + self::assertSame($assignment->overrides(), $assignment->overrides()); + self::assertSame(['modulePreset' => ['bl00000001']], $assignment->clearOverrides($local)); + } +} +``` + +Run: `composer test -- --filter PresetAssignmentTest` +Expected: FAIL, 15 Fehler `Error: Class "LIT\MCP\Integrations\Divi\Design\PresetAssignment" not found`. + +- [ ] **Step 4: `PresetAssignment` implementieren** + +Datei `src/Integrations/Divi/Design/PresetAssignment.php`: + +```php +>|null Preset ID => stored item. */ + private ?array $index = null; + + private ?PresetOverrides $overrides = null; + + /** + * @param Closure(): PresetBook $book The inventory (DesignSystemReader), called lazily. + * @param Closure(string): array $groupsOf DesignSystemWriter::groupsOf(): groupId => name. + * @param Closure(string): bool $validId True when the ID passes the ValueCleaner unchanged. + */ + public function __construct(private Closure $book, private Closure $groupsOf, private Closure $validId) + { + } + + /** + * Applies "preset" and "group_presets" of $spec (only the keys present) to the attributes of a $module block. + * null (or "" for "preset") removes the assignment; a string or a list of 1–10 strings sets it. + * + * @param array $attrs + * @param array $spec + * @return array{attrs: array, errors: array} Error paths: "$path.preset", + * "$path.preset." (one entry of a list), "$path.group_presets" and "$path.group_presets.". + */ + public function apply(string $module, array $attrs, array $spec, string $path): array + { + $errors = []; + if (array_key_exists('preset', $spec)) { + $attrs = $this->stack($module, $attrs, $spec['preset'], $path . '.preset', $errors); + } + + if (array_key_exists('group_presets', $spec)) { + $attrs = $this->groups($module, $attrs, $spec['group_presets'], $path . '.group_presets', $errors); + } + + return ['attrs' => $attrs, 'errors' => $errors]; + } + + /** + * The attributes without the local values that lie on values of the presets the block names (ruling R4). + * + * @param array $attrs + * @return array + */ + public function clearOverrides(array $attrs): array + { + return $this->overrides()->clear($attrs); + } + + /** The same instance every time, so the Warnings and clear_overrides read one inventory. */ + public function overrides(): PresetOverrides + { + $this->overrides ??= new PresetOverrides(fn (string $id): ?array => $this->items()[$id] ?? null); + + return $this->overrides; + } + + /** @return array Preset ID => name, module and group presets. */ + public function names(): array + { + $names = []; + foreach ($this->items() as $id => $item) { + if (is_string($item['name'] ?? null)) { + $names[$id] = $item['name']; + } + } + + return $names; + } + + /** + * @param array $attrs + * @param array $errors + * @return array + */ + private function stack(string $module, array $attrs, mixed $preset, string $path, array &$errors): array + { + if ($preset === null || $preset === '') { + unset($attrs['modulePreset']); + + return $attrs; + } + + $ids = $this->resolve( + new PresetScope(PresetScope::MODULE, $module), + $preset, + $path, + true, + 'leere Liste – null entfernt das Preset', + $errors + ); + if ($ids !== null) { + $attrs['modulePreset'] = $ids; + } + + return $attrs; + } + + /** + * @param array $attrs + * @param array $errors + * @return array + */ + private function groups(string $module, array $attrs, mixed $wanted, string $path, array &$errors): array + { + if (! is_array($wanted) || ($wanted !== [] && array_is_list($wanted))) { + $errors[$path] = 'Objekt Gruppe → Preset erwartet'; + + return $attrs; + } + + $groups = ($this->groupsOf)($module); + $entries = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : []; + foreach ($wanted as $key => $value) { + $keyPath = $path . '.' . $key; + try { + $groupId = GroupKeys::resolve($groups, (string) $key); + } catch (InvalidArgumentException $e) { + $errors[$keyPath] = $e->getCode() === GroupKeys::UNKNOWN + ? sprintf('%s in %s; vorhanden: %s', $e->getMessage(), $module, self::shortNames($groups)) + : $e->getMessage(); + continue; + } + + if ($value === null) { + unset($entries[$groupId]); + continue; + } + + $name = $groups[$groupId]; + $ids = $this->resolve( + new PresetScope(PresetScope::GROUP, $name), + $value, + $keyPath, + false, + 'leere Liste – null entfernt das Gruppen-Preset', + $errors + ); + if ($ids !== null) { + $entries[$groupId] = ['presetId' => $ids, 'groupName' => $name]; + } + } + + if ($entries === []) { + unset($attrs['groupPreset']); + } else { + $attrs['groupPreset'] = $entries; + } + + return $attrs; + } + + /** + * A name, an ID or a list of 1–10 of them to the IDs in the same order; null when anything is wrong (the reasons + * are in $errors: one per list entry at "$path." when $perEntry, else the first one at $path). + * + * @param array $errors + * @return list|null + */ + private function resolve( + PresetScope $scope, + mixed $value, + string $path, + bool $perEntry, + string $emptyMessage, + array &$errors + ): ?array { + $single = is_string($value); + if (! $single && ! (is_array($value) && array_is_list($value))) { + $errors[$path] = 'Text, Liste von Texten oder null erwartet'; + + return null; + } + + $entries = $single ? [$value] : $value; + if ($entries === [] || count($entries) > self::MAX_STACK) { + $errors[$path] = $entries === [] ? $emptyMessage : 'höchstens ' . self::MAX_STACK . ' Presets im Stapel'; + + return null; + } + + $ids = []; + $failed = false; + foreach ($entries as $position => $entry) { + try { + $ids[] = $this->idOf($scope, $entry, $ids); + } catch (InvalidArgumentException $e) { + $errors[$single || ! $perEntry ? $path : $path . '.' . $position] ??= $e->getMessage(); + $failed = true; + } + } + + return $failed ? null : $ids; + } + + /** + * @param list $taken IDs of the entries before this one. + * @throws InvalidArgumentException The German reason the entry cannot be used. + */ + private function idOf(PresetScope $scope, mixed $entry, array $taken): string + { + if (! is_string($entry)) { + throw new InvalidArgumentException('Preset-Name oder -ID als Text erwartet'); + } + + $found = $this->book()->find($scope, $entry); + if ($found === null) { + throw new InvalidArgumentException($scope->kind === PresetScope::MODULE + ? sprintf('unbekanntes Preset; vorhanden für %s: %s', $scope->name, $this->namesIn($scope)) + : sprintf('unbekanntes Gruppen-Preset "%s" (%s)', $entry, $scope->name)); + } + + $id = (string) $found['id']; + if (! ($this->validId)($id)) { + throw new InvalidArgumentException(sprintf('Preset-ID "%s" ist ungültig', $id)); + } + if (in_array($id, $taken, true)) { + throw new InvalidArgumentException(sprintf('Preset "%s" steht mehrfach im Stapel', $id)); + } + + return $id; + } + + private function namesIn(PresetScope $scope): string + { + $names = array_column($this->book()->items($scope), 'name'); + + return $names === [] ? '(keine)' : implode(', ', $names); + } + + /** @param array $groups groupId => groupName */ + private static function shortNames(array $groups): string + { + $names = array_unique(array_map( + static fn (string $name): string => str_starts_with($name, 'divi/') ? substr($name, 5) : $name, + array_values($groups) + )); + + return $names === [] ? '(keine)' : implode(', ', $names); + } + + private function book(): PresetBook + { + $this->loaded ??= ($this->book)(); + + return $this->loaded; + } + + /** @return array> */ + private function items(): array + { + if ($this->index === null) { + $this->index = []; + foreach ($this->book()->data() as $records) { + foreach ($records as $record) { + foreach ($record['items'] as $id => $item) { + $this->index[(string) $id] = $item; + } + } + } + } + + return $this->index; + } +} +``` + +Run: `composer test -- --filter PresetAssignmentTest` +Expected: PASS (15 Tests). + +- [ ] **Step 5: `PresetComposerTest` – Test für die Mehrdeutigkeit (RED)** + +In `tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` bekommt die Hilfsmethode `composer()` den optionalen Parameter `?\Closure $groupsOf = null` (Standard bleibt die bisherige Gruppenliste, bestehende Tests unverändert), und vor `testNewPresetNeedsAName` kommt der neue Test: + +```diff +--- a/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php ++++ b/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php +@@ -34,7 +34,7 @@ final class PresetComposerTest extends TestCase + }; + } + +- private static function composer(bool $unfiltered = true): PresetComposer ++ private static function composer(bool $unfiltered = true, ?\Closure $groupsOf = null): PresetComposer + { + return new PresetComposer( + ComposeFixture::composer($unfiltered), +@@ -44,7 +44,7 @@ final class PresetComposerTest extends TestCase + ComposeFixture::sanitizer($unfiltered) + ), + self::presetAttrs(), +- static fn (string $module): array => [ ++ $groupsOf ?? static fn (string $module): array => [ + 'module.decoration.spacing' => 'divi/spacing', + 'button.decoration.font' => 'divi/font', + ], +@@ -204,6 +204,39 @@ final class PresetComposerTest extends TestCase + self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']); + } + ++ /** Ruling R3: GroupKeys replaces the first-match search – a name two groups share needs the path. */ ++ public function testAmbiguousGroupNameIsAnErrorAndThePathResolvesIt(): void ++ { ++ $groupsOf = static fn (string $module): array => [ ++ 'button.decoration.font' => 'divi/font', ++ 'title.decoration.font' => 'divi/font', ++ ]; ++ $book = self::book()->with(new PresetScope('group', 'divi/font'), [ ++ 'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Schrift', 'groupName' => 'divi/font', ++ 'groupId' => 'title.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [], ++ ]); ++ $composer = self::composer(true, $groupsOf); ++ $scope = new PresetScope('module', 'divi/button'); ++ ++ $ambiguous = $composer->compose($book, $scope, null, [ ++ 'name' => 'X', 'group_presets' => ['font' => 'Schrift'], ++ ], 'items[0]', null); ++ self::assertSame( ++ 'Gruppe "font" ist mehrdeutig; bitte den Attributpfad angeben: ' ++ . 'button.decoration.font, title.decoration.font.', ++ $ambiguous['errors']['items[0].group_presets.font'] ++ ); ++ ++ $byPath = $composer->compose($book, $scope, null, [ ++ 'name' => 'X', 'group_presets' => ['title.decoration.font' => 'Schrift'], ++ ], 'items[0]', null); ++ self::assertSame([], $byPath['errors']); ++ self::assertSame( ++ ['title.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']], ++ $byPath['item']['groupPresets'] ++ ); ++ } ++ + public function testNewPresetNeedsAName(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [], +``` + +Run: `composer test -- --filter testAmbiguousGroupNameIsAnErrorAndThePathResolvesIt` +Expected: FAIL mit `Undefined array key "items[0].group_presets.font"` (die alte Suche wählt still die erste Gruppe, es gibt keinen Fehler). + +- [ ] **Step 6: `PresetComposer` auf `GroupKeys` umstellen** + +In `src/Integrations/Divi/Design/PresetComposer.php` ersetzt `GroupKeys::resolve()` die private Suche `findGroup()` (sie entfällt); die Meldung für eine unbekannte Gruppe bleibt Zeichen für Zeichen die alte, `GroupKeys` steht im selben Namespace (kein `use` nötig): + +```diff +--- a/src/Integrations/Divi/Design/PresetComposer.php ++++ b/src/Integrations/Divi/Design/PresetComposer.php +@@ -199,9 +199,12 @@ final class PresetComposer + $groups = ($this->groupsOf)($module); + foreach ($wanted as $group => $preset) { + $path = $field . '.group_presets.' . $group; +- $groupId = self::findGroup($groups, (string) $group); +- if ($groupId === null) { +- $errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module); ++ try { ++ $groupId = GroupKeys::resolve($groups, (string) $group); ++ } catch (InvalidArgumentException $e) { ++ $errors[$path] = $e->getCode() === GroupKeys::UNKNOWN ++ ? sprintf('keine Gruppe "%s" in %s', $group, $module) ++ : $e->getMessage(); + continue; + } + if ($preset === null) { +@@ -226,18 +229,6 @@ final class PresetComposer + return $nested; + } + +- /** @param array $groups groupId => groupName */ +- private static function findGroup(array $groups, string $wanted): ?string +- { +- foreach ($groups as $groupId => $name) { +- if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) { +- return (string) $groupId; +- } +- } +- +- return null; +- } +- + /** + * The name the ability resolved and cleaned ("name", also for a rename), else the existing preset's name. + * +``` + +Run: `composer test -- --filter "PresetComposerTest|PresetAssignmentTest|GroupKeysTest"` +Expected: PASS (`PresetComposerTest` 11 Tests – die bisherigen `testNestedGroupPresetsByShortName` und `testUnknownGroupAndPresetAreErrors` unverändert grün). + +- [ ] **Step 7: Gesamtlauf, Lint** + +Run: `composer test && composer lint` +Expected: alle Tests grün (2493 Tests: 2474 plus 3 `GroupKeysTest`, 15 `PresetAssignmentTest`, 1 `PresetComposerTest`), phpcs ohne Meldung. `DiviBoundaryTest` bleibt grün (keine Divi-Aufrufe). + +- [ ] **Step 8: Commit** + +Commit wie in den Global Constraints mit der Nachricht `feat: GroupKeys und PresetAssignment – Preset-Stapel und Gruppen-Presets über eine Engstelle`. Stagen: + +```bash +git add src/Integrations/Divi/Design/GroupKeys.php src/Integrations/Divi/Design/PresetAssignment.php src/Integrations/Divi/Design/PresetComposer.php tests/Unit/Integrations/Divi/Design/GroupKeysTest.php tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php tests/Unit/Integrations/Divi/Design/PresetComposerTest.php +``` + +--- + +### Offene Punkte + +- **Mehrdeutige Gruppennamen im `PresetComposer` (Task 3):** R3 sagt „Verhalten dort unverändert“, verlangt aber für alle Schreibwege einen Fehler bei mehreren gleichnamigen Gruppen. Der Plan folgt R3 und ändert damit genau diesen Randfall (vorher still die erste Gruppe). Bitte bestätigen. +- **`group` in `lit-divi-node-get` (Task 1):** R6 nennt nur den Schlüssel `group`; der Plan liefert den Kurznamen (`font-body`, derselbe Schlüssel wie in `group_presets` und in der Outline), nicht den vollen Namen `divi/font-body`. `group_id` liefert den Pfad. +- **Warnungstext (Task 2):** R5 zeigt nur den Attributpfad (`module.decoration.spacing`), nicht den Unterpfad (`padding.top`); der Plan hält sich daran und zählt Blätter mit gleichem Pfad einmal. Soll die Warnung den Unterpfad nennen, ändert sich nur `PresetOverrides::label()`. +- **Task 4 (Wiring), Hinweis:** `PresetAssignment` liest Presets aus einem `PresetBook`, `Composer::preset()` heute aus `DesignSystem`. Task 4 muss die Book-Closure aus `DesignSystemWriter::presets()` (bzw. dem Bestand der `presets`-Fläche) bauen und `$validId` so verdrahten, dass er die bisherige `ValueCleaner`-Prüfung von `Composer::preset()` übernimmt. + +--- + +### Task 4: Composer – Preset-Stapel, `group_presets` und `clear_overrides` über `PresetAssignment` + +Der `Composer` ruft für Presets nur noch `PresetAssignment` auf (Engstelle D7, R2, R3, R7). Reihenfolge je Knoten: `preset`/`group_presets` → Kurzform `set` → `attrs` → `clear_overrides` zuletzt (danach nur noch das Admin-Label). Die Fehlertexte für einzelne Namen bleiben bytegleich („unbekanntes Preset; vorhanden für …“, Mehrdeutigkeit S13a, „Preset-ID … ist ungültig“); `PresetAssignment` (Task 3) liefert sie, der `Composer` setzt nur die Schlüssel `.preset` bzw. `.preset.` ein. `DiviComposition` bekommt die Quelle der Presets (`DesignSystemWriter`), `DiviIntegration` verdrahtet sie an allen vier Stellen, die eine `DiviComposition` bauen. Das Eingabeschema von `lit/divi-update` und die Beschreibungen von `lit/divi-insert`, `lit/divi-update`, `lit/content-create` nennen die neuen Felder. + +**Entscheidungen:** +- `Composer::__construct` bekommt `PresetAssignment $presets` **als Pflichtparameter anstelle von `DesignSystem $design` (gleiche Position)**: `$design` wurde nur für Presets gebraucht. Aufwand: genau zwei Aufrufer in Tests (`ComposeFixture::composer()`, `ComposerTest::testBuilderVersionMustBeAVersion`) und einer in der Produktion (`DiviComposition::composer()`). Ein optionaler Parameter würde einen stillen Rückfall auf „keine Presets“ erlauben und die Engstelle aufweichen. +- `DiviComposition::__construct` bekommt am Ende `?DesignSystemWriter $presetSource = null` (benannter Parameter `presetSource:`). `null` = keine Presets (Unit-Tests, die keine Presets schreiben: leeres `PresetBook`, `groupsOf` = `[]`). Alle Produktions-Stellen laufen über **eine** private Methode `DiviIntegration::composition()`, damit die Verdrahtung nicht an einer Stelle vergessen wird; `DiviCompositionTest` prüft sie mit einem Writer-Mock. +- Die Prüfung „Preset-ID passiert den `ValueCleaner` unverändert“ wird zu `ValueCleaner::keepsId(string $id): bool` (die frühere Logik in `Composer::preset()`); `DiviComposition` und `ComposeFixture` reichen `$cleaner->keepsId(...)` als `validId`-Closure an `PresetAssignment`. Zusätzlich lehnt `keepsId()` Divi-Verweise (`$variable(…)$`) als ID ab (eine ID ist nie ein Verweis). +- Ein leerer String bei `preset` bleibt wie bisher „entfernen“ (der `Composer` macht `""` zu `null`, bevor er `PresetAssignment::apply()` aufruft). +- `clear_overrides` wird nach `attrs` angewandt, **und bei neuen Überschriften nach dem Standard-Level** (`defaultLevel()`), damit ein Preset-`headingLevel` nicht vom automatischen `h2` überdeckt bleibt. + +**Files:** +- Modify: `src/Integrations/Divi/Compose/ValueCleaner.php` (`keepsId()`) +- Modify: `src/Integrations/Divi/Compose/Composer.php` +- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `src/Integrations/Divi/Pages/DiviUpdateAbility.php` (Schema, Beschreibung) +- Modify: `src/Integrations/Divi/Pages/DiviInsertAbility.php` (Beschreibung) +- Modify: `src/Integrations/WordPress/Content/ContentCreateAbility.php` (Beschreibung) +- Modify: `tests/Support/ComposeFixture.php` (`bookOf()`, `presetAssignment()`, `composer()` mit `$book`/`$groupsOf`) +- Test: `tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php` +- Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php` (neue Tests; `testBuilderVersionMustBeAVersion` angepasst) +- Test: `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php` (Helfer `composition()` angepasst, neue Tests) +- Test: `tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php` + +**Interfaces:** +- Consumes (Task 3, exakt wie im Gerüst): `PresetAssignment::__construct(Closure $book, Closure $groupsOf, Closure $validId)`, `apply(string $module, array $attrs, array $spec, string $path): array{attrs, errors}`, `clearOverrides(array $attrs): array`; (Task 1) `NodeSummary::groupPresets(Block): array`; (vorhanden) `PresetBook::fromDivi()`, `DesignSystemWriter::presets()`, `DesignSystemWriter::groupsOf()`. +- Produces: + - `ValueCleaner::keepsId(string $id): bool` + - `Composer::__construct(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, ValueCleaner, PresetAssignment $presets, string $builderVersion)` + - Knoten-Schlüssel `preset` (String | Liste | `null`), `group_presets` (Objekt), `clear_overrides` (bool) in `compose()` (alle Knotentypen) und `update()` (`UPDATE` um `group_presets`, `clear_overrides` erweitert); Fehlerschlüssel `.preset`, `.preset.`, `.group_presets.`, `.clear_overrides`. + - `DiviComposition::__construct(…, ?DesignSystemWriter $presetSource = null)`, `DiviComposition::presets(): PresetAssignment` (`@internal`, öffentlich für Tests und für `warnings()`). + - `ComposeFixture::bookOf(DesignSystem): PresetBook`, `ComposeFixture::presetAssignment(?PresetBook $book = null, ?Closure $groupsOf = null, ?DesignSystem $design = null, ?ValueCleaner $cleaner = null): PresetAssignment`, `ComposeFixture::composer(bool $unfiltered = false, ?DesignSystem $design = null, ?PresetBook $book = null, ?Closure $groupsOf = null)`. + +- [ ] **Step 1: Failing test für `ValueCleaner::keepsId()`** + +In `tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php` (Imports `ComposeFixture`, `InvalidArgumentException` sind vorhanden) vor der letzten `}` der Klasse ergänzen: + +```php + /** Design-system ids (preset ids) are no client text, but they pass the choke point unchanged (ruling S13). */ + public function testKeepsIdAcceptsPlainIdsAndRefusesEverythingTheCleanerChanges(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertTrue($cleaner->keepsId('3gbmxlurey')); + self::assertTrue($cleaner->keepsId('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')); + self::assertFalse($cleaner->keepsId('p'), 'tags would be stripped'); + self::assertFalse($cleaner->keepsId('p%41x'), 'octets would be stripped'); + self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped'); + self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference'); + } +``` + +- [ ] **Step 2: RED** + +Run: `composer test -- --filter testKeepsIdAcceptsPlainIds` +Expected: FAIL, `Error: Call to undefined method LIT\MCP\Integrations\Divi\Compose\ValueCleaner::keepsId()`. + +- [ ] **Step 3: `keepsId()` implementieren** + +In `src/Integrations/Divi/Compose/ValueCleaner.php` nach der Konstante `BUILDER_ONLY` (vor `public function __construct`) die Konstante ergänzen: + +```php + /** Module the catalog routes design-system ids through; the kind is fixed to text, so any module will do. */ + private const ID_MODULE = 'divi/text'; +``` + +und direkt nach `clean()` (vor `public function kindOf(`) die Methode einfügen: + +```php + /** + * Whether $id passes clean() unchanged as plain text – the check for ids that come from the design system + * (preset ids, ruling S13): an id the sanitizers would change or refuse is never stored, and an id is never + * a Divi reference. + */ + public function keepsId(string $id): bool + { + if (VariableRef::contains($id)) { + return false; + } + + try { + return $this->clean(self::ID_MODULE, 'modulePreset', $id, null, ValueKind::Text) === $id; + } catch (InvalidArgumentException) { + return false; + } + } +``` + +(`InvalidArgumentException`, `VariableRef` und `ValueKind` sind in Datei/Namespace vorhanden.) + +- [ ] **Step 4: GREEN** + +Run: `composer test -- --filter testKeepsIdAcceptsPlainIds` +Expected: PASS. + +- [ ] **Step 5: Test-Unterbau und Failing Tests für Composer und `DiviComposition`** + +**5a. `tests/Support/ComposeFixture.php`** – Imports ergänzen (alphabetisch einsortieren): + +```php +use Closure; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; +``` + +Die Methode `composer()` am Dateiende (samt Docblock) ersetzen durch: + +```php + /** + * Builder version 5.2.1. $design replaces the sample design system (one Placeholders, one + * VariableRef and one ValueCleaner shared by AttrWriter, ShortformWriter and Composer, like the + * live wiring – ruling S31). The presets come from $book (default: the presets of $design, see + * bookOf()); $groupsOf names the option groups per module (default: none). + * + * @param (Closure(string): array)|null $groupsOf + */ + public static function composer( + bool $unfiltered = false, + ?DesignSystem $design = null, + ?PresetBook $book = null, + ?Closure $groupsOf = null + ): Composer { + $design ??= DiviFixtures::designSystem(); + $placeholders = new Placeholders($design); + $cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered)); + $attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, $cleaner); + + return new Composer( + DiviFixtures::catalog(), + self::rules(), + new ShortformWriter($placeholders, self::media(), $cleaner), + $attrWriter, + $cleaner, + self::presetAssignment($book, $groupsOf, $design, $cleaner), + '5.2.1' + ); + } + + /** + * The presets of $design in Divi's stored format (ids, names, defaults; no attrs) as a PresetBook – so the tests + * that hand a DesignSystem to composer() keep their presets. + */ + public static function bookOf(DesignSystem $design): PresetBook + { + $raw = ['module' => [], 'group' => []]; + foreach ($design->presets() as $preset) { + $raw[$preset['kind']][$preset['module']] ??= ['default' => '', 'items' => []]; + $raw[$preset['kind']][$preset['module']]['items'][$preset['id']] = [ + 'id' => $preset['id'], + 'name' => $preset['name'], + $preset['kind'] === 'module' ? 'moduleName' : 'groupName' => $preset['module'], + ]; + if ($preset['default']) { + $raw[$preset['kind']][$preset['module']]['default'] = $preset['id']; + } + } + + return PresetBook::fromDivi($raw); + } + + /** + * PresetAssignment as the live wiring builds it: the book is $book (default: bookOf($design), else of the sample + * design system), the id check is the cleaner's keepsId(). + * + * @param (Closure(string): array)|null $groupsOf + */ + public static function presetAssignment( + ?PresetBook $book = null, + ?Closure $groupsOf = null, + ?DesignSystem $design = null, + ?ValueCleaner $cleaner = null + ): PresetAssignment { + $book ??= self::bookOf($design ?? DiviFixtures::designSystem()); + $cleaner ??= self::cleaner(); + + return new PresetAssignment( + static fn (): PresetBook => $book, + $groupsOf ?? static fn (string $module): array => [], + $cleaner->keepsId(...) + ); + } +``` + +**5b. `tests/Unit/Integrations/Divi/Compose/ComposerTest.php`** – Imports ergänzen: + +```php +use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; +use LIT\MCP\Integrations\Divi\Design\PresetBook; +``` + +In `testBuilderVersionMustBeAVersion` die Zeile `DiviFixtures::designSystem(),` (6. Argument) ersetzen durch `ComposeFixture::presetAssignment(),`. + +Vor `testBuilderVersionMustBeAVersion` einfügen (Hilfsmethoden und Tests): + +```php + /** Button presets p-1/p-2 as in the sample design system; p-2 carries a font color, g-1 is a font group preset. */ + private static function coveringBook(): PresetBook + { + return PresetBook::fromDivi([ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => [ + 'id' => 'p-2', + 'name' => 'CTA primär', + 'moduleName' => 'divi/button', + 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#ffffff']], + ]]]]], + ], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ]); + } + + /** @return array The option groups the tests give every button. */ + private static function buttonGroups(string $module): array + { + return $module === 'divi/button' ? ['button.decoration.font.font' => 'divi/font'] : []; + } + + public function testPresetStackIsStoredInTheGivenOrder(): void + { + $composer = ComposeFixture::composer(); + + $result = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => ['CTA primär', 'p-1']]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $result['errors']); + self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); + + $update = $composer->update(Block::create('divi/button'), ['preset' => ['Standard', 'CTA primär']], 'changes[0]'); + self::assertSame([], $update['errors']); + self::assertSame(['p-1', 'p-2'], $update['attrs']['modulePreset']); + } + + public function testPresetStackErrorsKeepTheirInputPaths(): void + { + $composer = ComposeFixture::composer(); + $button = Block::create('divi/button'); + + $unknown = $composer->update($button, ['preset' => ['Standard', 'Gibt es nicht']], 'changes[0]'); + self::assertSame( + 'unbekanntes Preset; vorhanden für divi/button: Standard, CTA primär', + $unknown['errors']['changes[0].preset.1'] + ); + self::assertArrayNotHasKey('modulePreset', $unknown['attrs']); + + foreach ([[], ['Standard', 'p-1']] as $invalid) { + $result = $composer->update($button, ['preset' => $invalid], 'changes[0]'); + self::assertNotSame([], preg_grep('/^changes\[0\]\.preset/', array_keys($result['errors']))); + self::assertArrayNotHasKey('modulePreset', $result['attrs']); + } + } + + public function testGroupPresetsAreAssignedByShortNameAndRemovedWithNull(): void + { + $composer = ComposeFixture::composer(false, null, null, self::buttonGroups(...)); + $groups = ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]]; + + $update = $composer->update(Block::create('divi/button'), ['group_presets' => ['font' => 'Fließtext groß']], 'changes[0]'); + self::assertSame([], $update['errors']); + self::assertSame($groups, NodeSummary::groupPresets(Block::create('divi/button', $update['attrs']))); + + $created = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'group_presets' => ['divi/font' => 'g-1']]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $created['errors']); + self::assertSame($groups, NodeSummary::groupPresets($created['blocks'][0])); + + $removed = $composer->update(Block::create('divi/button', $update['attrs']), ['group_presets' => ['font' => null]], 'changes[0]'); + self::assertSame([], $removed['errors']); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/button', $removed['attrs']))); + self::assertArrayNotHasKey('groupPreset', $removed['attrs']); + + $unknown = $composer->update(Block::create('divi/button'), ['group_presets' => ['farbe' => 'x']], 'changes[0]'); + self::assertArrayHasKey('changes[0].group_presets.farbe', $unknown['errors']); + } + + /** Ruling R7: clear_overrides runs after set/attrs of the same node and removes only leaves a preset also sets. */ + public function testClearOverridesRunsAfterSetAndAttrsOnANewNode(): void + { + $composer = ComposeFixture::composer(false, null, self::coveringBook()); + $font = ['desktop' => ['color' => '#abcdef', 'family' => 'Open Sans']]; + + $kept = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => 'CTA primär', 'attrs' => ['button.decoration.font.font' => $font]]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $kept['errors']); + self::assertSame( + ['color' => '#abcdef', 'family' => 'Open Sans'], + AttrMap::desktop($kept['blocks'][0]->attrs(), 'button.decoration.font.font') + ); + + $cleared = $composer->compose( + [[ + 'type' => 'button', + 'text' => 'Los', + 'preset' => 'CTA primär', + 'clear_overrides' => true, + 'attrs' => ['button.decoration.font.font' => $font], + ]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $cleared['errors']); + $attrs = $cleared['blocks'][0]->attrs(); + self::assertSame(['p-2'], $attrs['modulePreset']); + self::assertSame(['family' => 'Open Sans'], AttrMap::desktop($attrs, 'button.decoration.font.font')); + self::assertSame('Los', AttrMap::desktop($attrs, 'button.innerContent')['text']); + } + + public function testClearOverridesOnAnUpdateKeepsEverythingNoPresetCovers(): void + { + $composer = ComposeFixture::composer(false, null, self::coveringBook()); + $attrs = ['modulePreset' => ['p-2'], 'button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#000000']], + ]]]]]; + + $result = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => true], 'changes[0]'); + self::assertSame([], $result['errors']); + self::assertSame(['modulePreset' => ['p-2']], $result['attrs']); + + $plain = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + $unchanged = $composer->update($plain, ['clear_overrides' => true], 'changes[0]'); + self::assertSame([], $unchanged['errors']); + self::assertSame($plain->attrs(), $unchanged['attrs']); + + $off = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => false], 'changes[0]'); + self::assertSame($attrs, $off['attrs']); + } + + public function testClearOverridesMustBeABoolean(): void + { + $composer = ComposeFixture::composer(); + + $update = $composer->update(Block::create('divi/button'), ['clear_overrides' => 'ja'], 'changes[0]'); + self::assertSame('true oder false erwartet', $update['errors']['changes[0].clear_overrides']); + + $compose = $composer->compose( + [['type' => 'section', 'clear_overrides' => null, 'modules' => []]], + null, + 'nodes' + ); + self::assertSame('true oder false erwartet', $compose['errors']['nodes[0].clear_overrides']); + } +``` + +**5c. `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php`** – Imports ergänzen: + +```php +use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +``` + +Den Helfer `composition()` ersetzen durch (neuer optionaler Parameter, durchgereicht als `presetSource:`): + +```php + private function composition(?DesignSystemWriter $presets = null): DiviComposition + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(DiviFixtures::catalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DiviFixtures::designSystem()); + $registry = new DynamicContentMap(function (int $postId): array { + $this->registryCalls[] = $postId; + + return DiviFixtures::dynamicContentRegistry(); + }); + + return new DiviComposition( + $modules, + $design, + ComposeFixture::sanitizer(), + ComposeFixture::media(), + $registry, + ComposeFixture::links(), + presetSource: $presets + ); + } + + /** The preset source of the live wiring: Divi's stored presets (raw format) and the option groups per module. */ + private function presetSource(int $reads = 1): DesignSystemWriter + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->expects('presets')->times($reads)->andReturn([ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ]); + $writer->allows('groupsOf')->andReturnUsing( + static fn (string $module): array => $module === 'divi/button' + ? ['button.decoration.font.font' => 'divi/font'] + : [] + ); + + return $writer; + } +``` + +Neue Tests am Ende der Klasse: + +```php + public function testPresetStackAndGroupsComeFromTheWritersBookAndAreReadOnce(): void + { + $composer = $this->composition($this->presetSource())->forPost(7)->composer(); + + $result = $composer->compose([ + ['type' => 'button', 'text' => 'Eins', 'preset' => ['CTA primär', 'Standard'], 'group_presets' => ['font' => 'g-1']], + ['type' => 'button', 'text' => 'Zwei', 'preset' => 'p-2'], + ], Block::create('divi/column'), 'nodes'); + + self::assertSame([], $result['errors']); + self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); + self::assertSame(['p-2'], $result['blocks'][1]->attrs()['modulePreset']); + self::assertSame( + ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]], + NodeSummary::groupPresets($result['blocks'][0]) + ); + } + + public function testThePresetBookIsNotReadWithoutAPresetInTheInput(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->shouldNotReceive('presets'); + $writer->shouldNotReceive('groupsOf'); + + $result = $this->composition($writer)->forPost(7)->composer()->compose( + [['type' => 'button', 'text' => 'Los']], + Block::create('divi/column'), + 'nodes' + ); + + self::assertSame([], $result['errors']); + } + + public function testWithoutAPresetSourceNoPresetExists(): void + { + $result = $this->composition()->forPost(7)->composer()->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => 'Standard']], + Block::create('divi/column'), + 'nodes' + ); + + self::assertSame( + 'unbekanntes Preset; vorhanden für divi/button: (keine)', + $result['errors']['nodes[0].preset'] + ); + } + + public function testOnePresetAssignmentPerBoundComposition(): void + { + $base = $this->composition($this->presetSource(0)); + $seven = $base->forPost(7); + + self::assertSame($seven->presets(), $seven->presets()); + self::assertNotSame($seven->presets(), $base->forPost(8)->presets()); + self::assertNotSame($base->presets(), $seven->presets()); + } +``` + +- [ ] **Step 6: RED** + +Run: `composer test -- --filter 'ComposerTest|DiviCompositionTest|ValueCleanerTest'` +Expected: FAIL. `ComposeFixture::composer()` baut den `Composer` mit `PresetAssignment` an der Stelle von `DesignSystem`: alle `ComposerTest`-Tests scheitern mit `TypeError: LIT\MCP\Integrations\Divi\Compose\Composer::__construct(): Argument #6 ($design) must be of type LIT\MCP\Integrations\Divi\Design\DesignSystem, LIT\MCP\Integrations\Divi\Design\PresetAssignment given`; `DiviCompositionTest` scheitert mit `Error: Unknown named parameter $presetSource` (bzw. `ArgumentCountError`/`Call to undefined method …::presets()`). + +- [ ] **Step 7: `Composer` umbauen** + +In `src/Integrations/Divi/Compose/Composer.php`: + +1. Import ersetzen: `use LIT\MCP\Integrations\Divi\Design\DesignSystem;` → `use LIT\MCP\Integrations\Divi\Design\PresetAssignment;` (die Zeile steht zwischen `…\BlockTree\Block;` und `…\Schema\ModuleCatalog;`). + +2. Klassen-Docblock, Absatz „Design-system names …“: den Teil `presets in DesignSystem::modulePresetId())` ersetzen durch `presets in PresetAssignment)`, und im ersten Absatz `presets by name;` durch `preset stacks and group presets by name, clear_overrides;`. + +3. Konstanten: + +```php + private const COMMON = ['type', 'label', 'preset', 'attrs']; +``` +→ +```php + private const COMMON = ['type', 'label', 'preset', 'group_presets', 'clear_overrides', 'attrs']; +``` +```php + private const UPDATE = ['id', 'set', 'attrs', 'preset', 'label']; +``` +→ +```php + private const UPDATE = ['id', 'set', 'attrs', 'preset', 'group_presets', 'clear_overrides', 'label']; + + /** The keys of a node that PresetAssignment::apply() reads (rulings R2, R3). */ + private const PRESET_KEYS = ['preset', 'group_presets']; +``` + +4. Konstruktor: `private DesignSystem $design,` → `private PresetAssignment $presets,`. + +5. Docblock von `update()`: Zeile `* sets the returned attrs. "preset": null and "label": null remove them.` ersetzen durch: + +```php + * sets the returned attrs. "preset": null and "label": null remove them; "preset" may be a stack (list), + * "group_presets" assigns group presets (null removes one), "clear_overrides": true removes the local values + * the assigned presets also set – after set and attrs (ruling R7). +``` +und `@param array $change id, set, attrs, preset, label` → `… $change id, set, attrs, preset, group_presets, clear_overrides, label`. + +6. In `module()` die Zeilen +```php + $attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context)); + if ($defaultLevel) { + $attrs = $this->defaultLevel($short, $attrs); + } +``` +ersetzen durch: +```php + $attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context)); + if ($defaultLevel) { + // The automatic level is no client value: a preset's headingLevel wins when overrides are cleared. + $attrs = $this->clearedOverrides($this->defaultLevel($short, $attrs), $spec, $path); + } +``` + +7. `presetAndAttrs()` komplett ersetzen. Alt (genau so im Code): + +```php + private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array + { + if (array_key_exists('preset', $spec)) { + $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + } + + if (! array_key_exists('attrs', $spec)) { + return $attrs; + } + + if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { + $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + + return $attrs; + } + + $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + // An unknown path is the more useful message on its key (it decides invalid_attr_path). + foreach ($result['invalid_paths'] as $invalid) { + $key = $path . '.attrs.' . $invalid; + if (isset($result['errors'][$key])) { + $this->errors[$key] = $result['errors'][$key]; + } + } + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + + return $result['attrs']; + } +``` + +Neu (Reihenfolge Presets → attrs → clear_overrides, ruling R7; der Teil für `attrs` wandert unverändert nach `fullForm()`): + +```php + private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array + { + $attrs = $this->presets($name, $attrs, $spec, $path); + if (array_key_exists('attrs', $spec)) { + $attrs = $this->fullForm($name, $attrs, $spec['attrs'], $path . '.attrs'); + } + + return $this->clearedOverrides($attrs, $spec, $path); + } + + /** + * @param array $attrs + * @return array + */ + private function fullForm(string $name, array $attrs, mixed $full, string $path): array + { + if (! is_array($full) || array_is_list($full)) { + $this->errors[$path] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + + return $attrs; + } + + $result = $this->attrWriter->apply($name, $attrs, $full, $path); + // An unknown path is the more useful message on its key (it decides invalid_attr_path). + foreach ($result['invalid_paths'] as $invalid) { + $key = $path . '.' . $invalid; + if (isset($result['errors'][$key])) { + $this->errors[$key] = $result['errors'][$key]; + } + } + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + + return $result['attrs']; + } +``` + +Das Docblock direkt über `presetAndAttrs()` bleibt (`@param … @return …`). + +8. Die alte Methode `preset()` (Docblock `@param array $attrs` / `@return …`, danach `private function preset(string $name, array $attrs, mixed $preset, string $path): array` bis zur schließenden Klammer nach `$attrs['modulePreset'] = [$id]; return $attrs;`) komplett ersetzen durch: + +```php + /** + * "preset" (name or id, a stack of them, null/"" = remove) and "group_presets" – through PresetAssignment, the + * one place presets reach a block (D7, rulings R2, R3). Its error keys already carry $path. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function presets(string $name, array $attrs, array $spec, string $path): array + { + $given = array_intersect_key($spec, array_flip(self::PRESET_KEYS)); + if ($given === []) { + return $attrs; + } + + if (($given['preset'] ?? null) === '') { + $given['preset'] = null; + } + + $result = $this->presets->apply($name, $attrs, $given, $path); + $this->errors += $result['errors']; + + return $result['attrs']; + } + + /** + * Ruling R7: "clear_overrides": true removes the local values that sit on a leaf an explicitly assigned preset + * also sets; it runs after set and attrs, so it also takes what this very call wrote. Not a boolean is an error. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function clearedOverrides(array $attrs, array $spec, string $path): array + { + if (! array_key_exists('clear_overrides', $spec)) { + return $attrs; + } + + if (! is_bool($spec['clear_overrides'])) { + $this->errors[$path . '.clear_overrides'] = 'true oder false erwartet'; + + return $attrs; + } + + return $spec['clear_overrides'] ? $this->presets->clearOverrides($attrs) : $attrs; + } +``` + +- [ ] **Step 8: `DiviComposition` und `DiviIntegration` verdrahten** + +`src/Integrations/Divi/Pages/DiviComposition.php`: + +1. Imports ergänzen (alphabetisch): `use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;`, `use LIT\MCP\Integrations\Divi\Design\PresetAssignment;`, `use LIT\MCP\Integrations\Divi\Design\PresetBook;`. + +2. Klassen-Docblock, vor dem Absatz „forPost() binds …“ ergänzen: + +```php + * Presets (D7) reach blocks through ONE PresetAssignment per composition: its book is Divi's stored preset + * inventory (DesignSystemWriter::presets()), read lazily and at most once – only when a write names a preset or a + * warning looks at a block that has one – and Divi's option groups come from DesignSystemWriter::groupsOf(). + * Without a preset source (null, unit tests) there are no presets. + * +``` + +3. Eigenschaft nach `private ?Composer $composer = null;`: + +```php + private ?PresetAssignment $assignment = null; +``` + +4. Konstruktor: nach `private LinkTargets $links = new WpLinkTargets()` ergänzen: + +```php + private LinkTargets $links = new WpLinkTargets(), + private ?DesignSystemWriter $presetSource = null +``` +(die bisherige letzte Parameterzeile bekommt dafür ein Komma). + +5. In `forPost()` nach `$bound->composer = null;` ergänzen: `$bound->assignment = null;` + +6. `composer()` – das Argument `$this->design->read(),` ersetzen durch `$this->presets(),`. + +7. Neue Methode vor `composer()`: + +```php + /** + * The one PresetAssignment of this composition (D7, rulings R2, R3): the book comes lazily from Divi's stored + * presets, ids must pass the choke point unchanged. + * + * @internal Public for tests; code uses composer() / warnings(). + */ + public function presets(): PresetAssignment + { + return $this->assignment ??= new PresetAssignment( + fn (): PresetBook => PresetBook::fromDivi($this->presetSource?->presets() ?? []), + fn (string $module): array => $this->presetSource?->groupsOf($module) ?? [], + $this->cleaner()->keepsId(...) + ); + } +``` + +`src/Integrations/Divi/DiviIntegration.php`: vier Aufrufe von `new DiviComposition($this->modules, $this->design)` (in `contentComposer()`, `themeBuilder()`, `writer()`, `presetComposer()`) ersetzen: + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +sed -i 's/new DiviComposition(\$this->modules, \$this->design)/$this->composition()/' \ + src/Integrations/Divi/DiviIntegration.php +grep -c 'new DiviComposition' src/Integrations/Divi/DiviIntegration.php # erwartet: 0 (nur noch in composition()) +``` +Danach in `composition()` (nächster Schritt) steht genau ein `new DiviComposition`. Vor `private function writer(DiviPageLoader $loader): DiviWriter` einfügen: + +```php + /** + * The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come + * from the design system writer, so a stack or group preset in any write tool resolves against the live book. + */ + private function composition(): DiviComposition + { + return new DiviComposition($this->modules, $this->design, presetSource: $this->designWriter); + } +``` +Der `sed` ersetzt die Aufrufe auch in `presetComposer()` (`$composition = $this->composition();`); `grep -c 'new DiviComposition'` liefert danach 1. + +- [ ] **Step 9: GREEN** + +Run: `composer test -- --filter 'ComposerTest|DiviCompositionTest|ValueCleanerTest'` +Expected: PASS. + +Run: `composer test` +Expected: PASS (die bestehenden Writer-/Ability-/Content-Tests bauen `DiviComposition` ohne `presetSource`: keine Presets, ihre Erwartungen schreiben keine Presets). + +- [ ] **Step 10: Failing Tests für Schema und Beschreibungen** + +In `tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php` vor der letzten `}` der Klasse: + +```php + public function testUpdateOffersPresetStacksGroupPresetsAndClearOverrides(): void + { + $change = $this->update()->inputSchema()['properties']['changes']['items']['properties']; + + self::assertSame(['string', 'array', 'null'], $change['preset']['type']); + self::assertSame(['type' => 'string'], $change['preset']['items']); + self::assertSame([1, 10], [$change['preset']['minItems'], $change['preset']['maxItems']]); + self::assertSame('object', $change['group_presets']['type']); + self::assertSame(['string', 'array', 'null'], $change['group_presets']['additionalProperties']['type']); + self::assertSame([1, 10], [ + $change['group_presets']['additionalProperties']['minItems'], + $change['group_presets']['additionalProperties']['maxItems'], + ]); + self::assertSame('boolean', $change['clear_overrides']['type']); + + $description = $this->update()->description(); + foreach (['group_presets', 'clear_overrides', 'das letzte Preset'] as $text) { + self::assertStringContainsString($text, $description); + } + self::assertStringContainsString('group_presets', $this->insert()->description()); + } + + public function testClearOverridesMustBeABoolean(): void + { + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'clear_overrides' => 'ja'], + ]]); + + self::assertToolError($result, 'validation_failed'); + self::assertSame( + 'true oder false erwartet', + self::clientError($result)['fields']['changes[0].clear_overrides'] + ); + self::assertSame([], $this->saved); + } + + /** A pure clear_overrides that finds no covering value changes nothing and saves nothing. */ + public function testClearOverridesWithoutAHitIsNoChange(): void + { + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'clear_overrides' => true], + ]]); + + self::assertStringStartsWith('Keine Änderung', $result['summary']); + self::assertSame([], $this->saved); + } +``` + +In `tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php` (Datei vorhanden) einen Test ergänzen: + +```php + public function testDescriptionNamesPresetStacksAndGroupPresets(): void + { + $description = $this->ability()->description(); + + self::assertStringContainsString('group_presets', $description); + self::assertStringContainsString('clear_overrides', $description); + } +``` +(`$this->ability()` = die Fabrikmethode des Tests; Name und Konstruktion des vorhandenen Tests übernehmen.) + +- [ ] **Step 11: RED** + +Run: `composer test -- --filter 'testUpdateOffersPresetStacks|testClearOverrides|testDescriptionNamesPresetStacks'` +Expected: FAIL, z. B. `Failed asserting that two arrays are identical` (`['string', 'null']` statt `['string', 'array', 'null']`) bzw. `Undefined array key "group_presets"`; `testClearOverridesMustBeABoolean` besteht bereits (Composer-Schritt 7) und bleibt grün. + +- [ ] **Step 12: Schema und Beschreibungen** + +`src/Integrations/Divi/Pages/DiviUpdateAbility.php`: + +Beschreibung (`description()`) ersetzen: + +```php + public function description(): string + { + return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: ' + . '{Breakpoint: Wert}}, preset, group_presets, clear_overrides, label}. null entfernt einen Wert. ' + . 'preset = Name/ID oder Liste (Stapel, bis 10; bei gleicher Priorität gewinnt das letzte Preset im ' + . 'Stapel); group_presets = {Gruppe: Preset}, z. B. {"font-body": "Fließtext"}. Überdecken lokale Werte ' + . 'ein Preset, meldet die Antwort das; clear_overrides: true entfernt sie (nach set/attrs). Nicht ' + . 'genannte Einstellungen bleiben unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht ' + . 'zugleich über set und attrs. tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.'; + } +``` + +Im Schema den Eintrag `'preset' => ['type' => ['string', 'null'], 'description' => 'Preset-Name; null entfernt.'],` ersetzen durch: + +```php + 'preset' => self::presetValue( + 'Preset-Name oder -ID, Liste (Stapel, 1–10) oder null zum Entfernen. Bei gleicher ' + . 'Priorität gewinnt das letzte Preset im Stapel.' + ), + 'group_presets' => [ + 'type' => 'object', + 'description' => 'Gruppen-Presets: {Gruppe: Preset-Name | Liste | null}. Gruppe = ' + . 'Kurzname (font-body), voller Name (divi/font-body) oder Attributpfad; null ' + . 'entfernt den Eintrag der Gruppe.', + 'additionalProperties' => self::presetValue(), + ], + 'clear_overrides' => [ + 'type' => 'boolean', + 'description' => 'true entfernt lokale Werte, die Werte der zugewiesenen Presets ' + . 'überdecken (nach set und attrs).', + ], +``` + +und am Ende der Klasse (nach `change()`) die Hilfsmethode ergänzen: + +```php + /** + * Schema of one preset value: a name or id, a stack of 1–10 of them, or null. + * + * @return array + */ + private static function presetValue(string $description = ''): array + { + $schema = [ + 'type' => ['string', 'array', 'null'], + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => 10, + ]; + + return $description === '' ? $schema : $schema + ['description' => $description]; + } +``` + +`src/Integrations/Divi/Pages/DiviInsertAbility.php` – in `description()` den Satz `'Platzhalter wie {{color:primary}} statt fester Werte. tree_rev ist Pflicht; alles oder nichts. '` ersetzen durch: + +```php + . 'Platzhalter wie {{color:primary}} statt fester Werte. Presets: preset = Name/ID oder Liste (Stapel; ' + . 'das letzte gewinnt bei gleicher Priorität), group_presets = {Gruppe: Preset}, clear_overrides: true ' + . 'entfernt lokale Werte, die ein Preset überdecken. tree_rev ist Pflicht; alles oder nichts. ' +``` +(die folgende Zeile `'Format: lit-guide topic "divi-shortform".'` bleibt). + +`src/Integrations/WordPress/Content/ContentCreateAbility.php` – in `description()` den Teil `von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content; das Ergebnis enthält '` durch folgenden ersetzen (Rest der Beschreibung unverändert): + +```php + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"; Module können preset – auch als Liste –, ' + . 'group_presets und clear_overrides tragen), statt content; das Ergebnis enthält ' +``` + +- [ ] **Step 13: GREEN, Gesamtlauf, Lint, Commit** + +Run: `composer test -- --filter 'testUpdateOffersPresetStacks|testClearOverrides|testDescriptionNamesPresetStacks'` +Expected: PASS. + +Run: `composer test && composer lint` +Expected: PASS, keine phpcs-Meldungen (Zeilen ≤ 120 Zeichen in `src/`). + +```bash +git add src/Integrations/Divi/Compose/ValueCleaner.php src/Integrations/Divi/Compose/Composer.php \ + src/Integrations/Divi/Pages/DiviComposition.php src/Integrations/Divi/DiviIntegration.php \ + src/Integrations/Divi/Pages/DiviUpdateAbility.php src/Integrations/Divi/Pages/DiviInsertAbility.php \ + src/Integrations/WordPress/Content/ContentCreateAbility.php tests/Support/ComposeFixture.php \ + tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php tests/Unit/Integrations/Divi/Compose/ComposerTest.php \ + tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php \ + tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php \ + tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +git commit -m "feat: Presets als Stapel, Gruppen-Presets und clear_overrides im Composer" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: Warnungen – lokale Werte überdecken zugewiesene Presets (R5) + +`Warnings::collect()` meldet für berührte Blöcke (und ihre Nachkommen), deren lokale Werte ein ausdrücklich zugewiesenes Preset überdecken, je Preset eine Zeile (Text und Pfadformat liefert `PresetOverrides::describe()`, R5). Reines PHP, keine WordPress-Aufrufe. Es kostet nur Arbeit, wo ein Block ausdrücklich Presets trägt: opake Blöcke (globale Instanzen) und Nicht-Divi-Blöcke werden übersprungen, und das Preset-Buch wird erst gelesen, wenn der erste berührte Block `modulePreset` oder `groupPreset` hat. + +**Entscheidung:** `Warnings` bekommt das `PresetAssignment`-Objekt direkt (`?PresetAssignment $presets = null`, `null` = keine Überdeckungswarnungen, der bisherige Aufruf `new Warnings($design)` bleibt gültig). Das Objekt ist selbst faul (Buch per Closure, Task 3); eine zusätzliche `Closure`-Schicht in `Warnings` wäre doppelte Faulheit. + +**Files:** +- Modify: `src/Integrations/Divi/Compose/Warnings.php` +- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` (`warnings()`) +- Test: `tests/Unit/Integrations/Divi/Compose/WarningsTest.php` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php` + +**Interfaces:** +- Consumes (Task 2/3, exakt wie im Gerüst): `PresetAssignment::overrides(): PresetOverrides`, `PresetAssignment::names(): array` (id => Name), `PresetOverrides::describe(string $nodeId, array $attrs, array $names): list`; (Task 1) `NodeSummary::groupPresets(Block): array`; (vorhanden) `NodeSummary::presetIds(Block): list`, `Block::isOpaque()`, `Block::isDivi()`; aus Task 4: `DiviComposition::presets()`, `ComposeFixture::presetAssignment()`. +- Produces: `Warnings::__construct(DesignSystem $design, ?PresetAssignment $presets = null)`; `Warnings::collect()` liefert zusätzlich die Zeilen von `PresetOverrides::describe()` je berührtem Divi-Block mit ausdrücklichen Presets (in der Reihenfolge Code, Bild, leer, feste Werte, Überdeckungen je Block; Überschriftensprünge wie bisher am Ende). + +- [ ] **Step 1: Failing Tests schreiben** + +In `tests/Unit/Integrations/Divi/Compose/WarningsTest.php` Imports ergänzen: + +```php +use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; +``` + +und vor der letzten `}` der Klasse einfügen: + +```php + /** @return array Button attrs with a preset and a local font color. */ + private static function button(string $color, string $preset = 'p-2'): array + { + return [ + 'modulePreset' => [$preset], + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => $color]]]]]], + ]; + } + + /** Preset p-2 "CTA primär" sets the font color #ffffff; g-1 is a group preset without values. */ + private static function book(): PresetBook + { + return PresetBook::fromDivi([ + 'module' => ['divi/button' => ['default' => '', 'items' => [ + 'p-2' => [ + 'id' => 'p-2', + 'name' => 'CTA primär', + 'moduleName' => 'divi/button', + 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#ffffff']], + ]]]]], + ], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ]); + } + + /** @param int $loads Counts how often the preset book is read. */ + private static function presets(int &$loads): PresetAssignment + { + return ComposeFixture::presetAssignment(null, null, null, null, static function () use (&$loads): PresetBook { + ++$loads; + + return self::book(); + }); + } + + /** + * @param list $blocks top-level blocks of the page + * @param list $touched + * @return array{0: list, 1: NodeIndex} + */ + private static function collect(array $blocks, array $touched, PresetAssignment $presets): array + { + $document = new Document(); + foreach ($blocks as $block) { + $document->append($block); + } + $index = NodeIndex::build($document); + $warnings = (new Warnings(DiviFixtures::designSystem(), $presets))->collect($index, $touched); + + return [ + array_values(array_filter($warnings, static fn (string $w): bool => str_contains($w, 'überdecken'))), + $index, + ]; + } + + public function testWarnsWhenLocalValuesCoverAnAssignedPreset(): void + { + $loads = 0; + $button = Block::create('divi/button', self::button('#000000')); + + [$covering, $index] = self::collect([$button], [$button], self::presets($loads)); + + self::assertCount(1, $covering); + self::assertStringStartsWith( + $index->idOf($button) . ': lokale Werte überdecken „CTA primär“: ', + $covering[0] + ); + self::assertStringContainsString('button.decoration.font.font', $covering[0]); + self::assertStringEndsWith('clear_overrides: true entfernt sie.', $covering[0]); + } + + public function testEqualValuesAndUntouchedBlocksDoNotWarn(): void + { + $loads = 0; + $same = Block::create('divi/button', self::button('#ffffff')); + $other = Block::create('divi/button', self::button('#000000')); + + [$covering] = self::collect([$same, $other], [$same], self::presets($loads)); + + self::assertSame([], $covering, 'the same value covers nothing; $other was not touched'); + } + + public function testChildrenOfATouchedBlockAreChecked(): void + { + $loads = 0; + $button = Block::create('divi/button', self::button('#000000')); + $section = Block::create('divi/section', [], [ + Block::create('divi/row', [], [Block::create('divi/column', [], [$button])]), + ]); + + [$covering, $index] = self::collect([$section], [$section], self::presets($loads)); + + self::assertCount(1, $covering); + self::assertStringStartsWith($index->idOf($button) . ': lokale Werte', $covering[0]); + } + + public function testThePresetBookIsOnlyReadForBlocksThatHavePresets(): void + { + $loads = 0; + $plain = Block::create('divi/button', [ + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]], + ]); + $presets = self::presets($loads); + + self::collect([$plain], [$plain], $presets); + self::assertSame(0, $loads, 'no touched block names a preset'); + + $grouped = Block::create('divi/button', [ + 'groupPreset' => ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetId' => ['g-1']]], + ]); + $stacked = Block::create('divi/button', self::button('#000000')); + [$covering] = self::collect([$plain, $grouped, $stacked], [$plain, $grouped, $stacked], $presets); + + self::assertSame(1, $loads, 'read once, for the first block that has a preset'); + self::assertCount(1, $covering); + } + + public function testOpaqueAndForeignBlocksAreSkipped(): void + { + $loads = 0; + $attrs = self::button('#000000'); + $opaque = Block::parsed( + 'divi/button', + $attrs + ['globalModule' => '9'], + '', + true, + true + ); + $foreign = Block::create('core/paragraph', $attrs); + + [$covering] = self::collect([$opaque, $foreign], [$opaque, $foreign], self::presets($loads)); + + self::assertSame([], $covering); + self::assertSame(0, $loads); + } +``` + +`ComposeFixture::presetAssignment()` bekommt dafür einen fünften Parameter für ein ladendes Buch: in `tests/Support/ComposeFixture.php` die Methode aus Task 4 so ändern, dass `$book` auch eine Closure sein darf. Signatur und Rumpf (ersetzt die Version aus Task 4): + +```php + /** + * PresetAssignment as the live wiring builds it: the book is $book (default: bookOf($design), else of the sample + * design system), the id check is the cleaner's keepsId(). $load replaces $book with a loader the test can + * count. + * + * @param (Closure(string): array)|null $groupsOf + * @param (Closure(): PresetBook)|null $load + */ + public static function presetAssignment( + ?PresetBook $book = null, + ?Closure $groupsOf = null, + ?DesignSystem $design = null, + ?ValueCleaner $cleaner = null, + ?Closure $load = null + ): PresetAssignment { + $book ??= self::bookOf($design ?? DiviFixtures::designSystem()); + $cleaner ??= self::cleaner(); + + return new PresetAssignment( + $load ?? static fn (): PresetBook => $book, + $groupsOf ?? static fn (string $module): array => [], + $cleaner->keepsId(...) + ); + } +``` +(`$book` wird nur ausgewertet, wenn `$load` fehlt; `bookOf()` ist billig.) + +In `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php` einen Test ergänzen (nutzt `presetSource()` aus Task 4; das Preset `p-2` bekommt dafür ein Attribut, daher den Rückgabewert der Raw-Daten in `presetSource()` um `'attrs'` erweitern – siehe unten): + +```php + public function testWarningsKnowTheWritersPresets(): void + { + $composition = $this->composition($this->presetSource())->forPost(7); + $button = Block::create('divi/button', [ + 'modulePreset' => ['p-2'], + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]], + ]); + $document = new Document(); + $document->append($button); + $index = NodeIndex::build($document); + + $warnings = $composition->warnings()->collect($index, [$button]); + + $covering = array_values(array_filter($warnings, static fn (string $w): bool => str_contains($w, 'überdecken'))); + self::assertCount(1, $covering); + self::assertStringContainsString('„CTA primär“', $covering[0]); + } +``` + +In `presetSource()` (Task 4) das Preset `p-2` um `attrs` erweitern: + +```php + 'p-2' => [ + 'id' => 'p-2', + 'name' => 'CTA primär', + 'moduleName' => 'divi/button', + 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#ffffff']], + ]]]]], + ], +``` +(`Document` und `NodeIndex` sind in `DiviCompositionTest` bereits importiert.) Die Task-4-Tests ändern dadurch ihr Verhalten nicht (Presets mit `attrs` werden beim Zuweisen nur gelesen). + +- [ ] **Step 2: RED** + +Run: `composer test -- --filter 'WarningsTest|testWarningsKnowTheWritersPresets'` +Expected: FAIL. `WarningsTest`: `ArgumentCountError: Too many arguments to function LIT\MCP\Integrations\Divi\Compose\Warnings::__construct(), 2 passed … and exactly 1 expected`; `testWarningsKnowTheWritersPresets`: `Failed asserting that actual size 0 matches expected size 1`. + +- [ ] **Step 3: `Warnings` implementieren** + +In `src/Integrations/Divi/Compose/Warnings.php`: + +1. Import ergänzen: `use LIT\MCP\Integrations\Divi\Design\PresetAssignment;` (nach `…\Design\DesignSystem;`). + +2. Klassen-Docblock: den ersten Satz ergänzen – `… the code module.` → `… the code module, local values that cover an assigned preset (ruling R5).` + +3. Konstruktor: + +```php + public function __construct(private DesignSystem $design, private ?PresetAssignment $presets = null) + { + } +``` + +4. In `collect()` den Aufruf im Schleifenkörper ergänzen: + +```php + array_push( + $warnings, + ...$this->code($block, $id), + ...$this->image($block, $id), + ...$this->empty($block, $id), + ...$this->fixedValues($block, $id), + ...$this->coveredPresets($block, $id) + ); +``` + +5. Neue Methoden nach `fixedValues()` (vor dem Docblock von `headings()`): + +```php + /** + * Ruling R5: local values that cover an explicitly assigned preset of the block. Only Divi blocks that name a + * preset cost work (opaque blocks – global instances – are never looked into); the preset book is read when the + * first such block comes up, never for a page without presets. + * + * @return list + */ + private function coveredPresets(Block $block, string $id): array + { + if ($this->presets === null || ! $block->isDivi() || $block->isOpaque() || ! self::namesPresets($block)) { + return []; + } + + return $this->presets->overrides()->describe($id, $block->attrs(), $this->presets->names()); + } + + private static function namesPresets(Block $block): bool + { + return NodeSummary::presetIds($block) !== [] || NodeSummary::groupPresets($block) !== []; + } +``` + +`src/Integrations/Divi/Pages/DiviComposition.php` – `warnings()` ersetzen: + +```php + public function warnings(): Warnings + { + return new Warnings($this->design->read(), $this->presets()); + } +``` + +- [ ] **Step 4: GREEN** + +Run: `composer test -- --filter 'WarningsTest|DiviCompositionTest'` +Expected: PASS (inklusive des unveränderten `testWarnsAboutTheTouchedNodesOnly`, dessen Warnungsliste und Reihenfolge ohne Presets gleich bleibt). + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` +Expected: PASS. + +```bash +git add src/Integrations/Divi/Compose/Warnings.php src/Integrations/Divi/Pages/DiviComposition.php \ + tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/WarningsTest.php \ + tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +git commit -m "feat: Warnung, wenn lokale Werte ein zugewiesenes Preset überdecken" \ + -m "Co-Authored-By: Claude Haiku 5.5 " +``` + +--- + +## Offene Punkte (Tasks 4–5) + +Anforderungen an **Task 3** (`PresetAssignment`, `GroupKeys`), damit Task 4/5 und die vorhandenen Tests halten: + +1. **Fehlertexte bytegleich zu heute** (`ComposerTest` prüft sie): einzelner unbekannter Name/ID: `unbekanntes Preset; vorhanden für : ` bzw. `… : (keine)`; Mehrdeutigkeit (S13a) durch `PresetBook::find()` bzw. `idsNamed()`: `Preset-Name "" ist mehrdeutig; bitte die ID angeben: p-1, p-3.` (gleicher Text wie `PresetBook::find()`); ID-Sperre: `Preset-ID "" ist ungültig`. Auflösung: exakte ID zuerst, sonst Name case-insensitiv (wie `find()`). +2. **Fehlerschlüssel**: Einzelstring → `.preset`; Listeneintrag → `.preset.`; leere Liste, Doppelte, Liste > 10, falscher Typ → `.preset`; Gruppen → `.group_presets.` (bei nicht-Objekt `.group_presets`). `ComposerTest` (Task 4) prüft `changes[0].preset.1` und per `preg_grep` `^changes\[0\]\.preset`. +3. **`apply()`**: `'preset' => null` entfernt den Schlüssel `modulePreset` ganz (`unset`, kein leeres Array); `'group_presets' => [k => null]` entfernt den Eintrag; ein dadurch leeres `groupPreset` wird entfernt (Test: `assertArrayNotHasKey('groupPreset', …)`). Der Composer normalisiert `""` bei `preset` selbst zu `null`. +4. **Faulheit**: `PresetAssignment` ruft die Buch-Closure höchstens einmal und erst, wenn ein Eintrag aufgelöst, `names()` gefragt oder `PresetOverrides::item` ausgeführt wird. `overrides()` und `clearOverrides()` dürfen das Buch **nicht** laden, solange der Block weder `modulePreset` noch `groupPreset` trägt (`PresetOverrides` ruft `$item` nur für vorhandene IDs auf). `names()` und `overrides()` sind memoisiert (`Warnings` ruft sie je Block auf). Task 5 (`testThePresetBookIsOnlyReadForBlocksThatHavePresets`) und `DiviUpdateAbilityTest::testClearOverridesWithoutAHitIsNoChange` hängen daran. +5. **`clearOverrides($attrs)`** gibt `$attrs` bytegleich zurück, wenn nichts zu entfernen ist (Task 4: `assertSame($plain->attrs(), …)`); `ComposerTest` erwartet, dass lokale Blätter außerhalb der Preset-Blätter (z. B. `family` neben dem Preset-`color`) bleiben und dass `['modulePreset' => ['p-2']]` übrig bleibt, wenn das letzte Blatt eines Pfads entfernt wurde (leere Eltern aufgeräumt). +6. **ID-Prüfung**: `validId` bekommt `ValueCleaner::keepsId()`; `PresetAssignment` fragt sie für jede aufgelöste Preset-ID (Modul- und Gruppen-Presets) und schreibt bei `false` `Preset-ID "" ist ungültig`. `testPresetIdsPassTheChokePoint` nutzt eine Preset-ID `p`, die der Name „Seltsam“ auflöst. +7. **Gruppenschlüssel** `font` (Kurzname) und `divi/font` (voller Name) müssen für `groupsOf('divi/button') = ['button.decoration.font.font' => 'divi/font']` beide `button.decoration.font.font` ergeben; der Preset-Name `Fließtext groß` bzw. die ID `g-1` löst im Gruppen-Bereich `PresetScope(group, 'divi/font')` auf; `NodeSummary::groupPresets()` (Task 1) liest das Ergebnis als `['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]]`. +8. **Fixture-Namen**: Task 4 legt `ComposeFixture::bookOf()` und `ComposeFixture::presetAssignment()` (in Task 5 um `$load` erweitert) an. Legt Task 3 für `PresetAssignmentTest` eigene Helfer in `ComposeFixture` an, müssen die Namen abgestimmt bzw. zusammengeführt werden; Task 4 setzt voraus, dass `ComposeFixture` bis dahin nur diese Änderungen von Task 4 enthält. + +Eigene Unklarheiten: + +9. **Pfadformat der Warnung** (R5) liegt bei Task 2 (`describe()`); die Tests in Task 5 prüfen deshalb nur Präfix `: lokale Werte überdecken „“: `, den Pfadanfang `button.decoration.font.font` und das Ende `clear_overrides: true entfernt sie.`. Sollte `describe()` bei `desktop`/`value` den Unterpfad `.color` anhängen, passt der Test weiterhin (`StringContains`). +10. **Preset-Quelle ohne Cache**: `DesignSystemWriter::presets()` liest `GlobalPreset::get_data()` bei jedem Aufruf frisch, `DesignSystemReader::read()` dagegen einmal je Request. Die Composition liest das Buch einmal je gebundener Composition (`forPost()` setzt es zurück); Namen für die Outline kommen weiter aus `DesignSystemReader::presetNames()`. Innerhalb eines Requests, der Presets ändert und danach schreibt, kann beides auseinanderlaufen – im MCP-Betrieb (ein Tool je Request) unkritisch, daher keine Zusatzlogik. +11. **`validId` mit festem Modul `divi/text`** in `ValueCleaner::keepsId()`: Die frühere Prüfung lief mit dem echten Modulnamen; mit fest gesetztem Text-Kind ist der Modulname für das Ergebnis ohne Einfluss (Katalog kennt `modulePreset` nirgends → `unknown`, Kind Text). Fällt `ValueCleanerTest::testKeepsId…` oder `testPresetIdsPassTheChokePoint` anders aus, `ID_MODULE` auf ein Modul mit bekanntem Katalog-Eintrag prüfen. + +--- + +### Task 6: Fundstellen aller Module eines Typs – `ModuleUsageQuery`; Standard-Gruppen-Presets (R11) + +**Files:** +- Create: `src/Integrations/Divi/Design/ModuleUsageQuery.php` +- Modify: `src/Integrations/Divi/Design/PresetUsageMatcher.php` (`$groupsOf`, `DEFAULT_IDS` öffentlich) +- Modify: `src/Integrations/Divi/Design/PresetUsageQuery.php` (Kandidaten, `withGroups()`) +- Modify: `src/Integrations/Divi/Design/DiviPresetsSetAbility.php` (Warnung entfällt, `withGroups`) +- Modify: `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php` (`DETACH_HINT`, `withGroups`) +- Modify: `src/Integrations/Divi/Design/DiviPresetGetAbility.php` (`withGroups`) +- Test: `tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php` (neu) +- Test: `tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php`, `PresetUsageQueryTest.php`, + `DiviPresetsSetAbilityTest.php`, `DiviPresetsDeleteAbilityTest.php` (ergänzt) + +**Interfaces:** +- Consumes: `UsageQuery`, `Parser::parse()`, `MalformedMarkup`, `NodeIndex::build()/entries()`, + `GlobalInstance::effectiveName()`, `NodeSummary::presetIds()`, `PresetScope`, `DesignSystemWriter::groupsOf()` + (vorhanden, `groupId => groupName`). +- Produces: + - `final class ModuleUsageQuery implements UsageQuery { public function __construct(private string $module) }` – + `$module` = vollständiger Blockname (`divi/button`), sonst `InvalidArgumentException`. `matches()`: ein Block (auch + ein opaker: globale Instanz mit `blockName`, Block mit `globalModule` oder unlesbaren Attributen) mit + `GlobalInstance::effectiveName() === $module`; Markup, das der Parser ablehnt, zählt als Treffer (fail closed). + - `PresetUsageMatcher::__construct(private Parser $parser = new Parser(), private ?Closure $groupsOf = null)`; + `public const DEFAULT_IDS` (war `private`; Task 7 braucht sie). Mit `$groupsOf` (`Closure(string $module): + array`, `groupId => groupName`) trifft ein Standard-Gruppen-Preset (`$isDefault`, Bereich `group`) + jeden nicht opaken Divi-Block, dessen Modul die Gruppe hat und der für diese `groupId` keinen eigenen + `groupPreset`-Eintrag (ID außer `''`/`default`/`_initial`) trägt. Ohne `$groupsOf` unverändertes 4a-Verhalten. + - `PresetUsageQuery::withGroups(array $targets, Closure $groupsOf): self` (Matcher mit `$groupsOf`); Kandidaten-SQL + enthält für jedes Standard-Gruppen-Preset zusätzlich `LIKE '%wp:divi/%'`. + +**Überlegungen zum Kandidaten-SQL von `ModuleUsageQuery` (fail closed):** + +- Ein Divi-5-Modul steht als `` (ohne Kinder) oder + `…` (mit Kindern), ohne Attribute ``. + Der Blockname im Kommentar wird nie JSON-kodiert (der `Parser` erwartet `wp:/` im Klartext); danach + folgt immer Leerraum (`\s+`, nicht nur ein Leerzeichen). Darum `LIKE '%divi/button%'` ohne `wp:` und ohne + abschließendes Leerzeichen; es trifft auch `divi/button-group` – `matches()` filtert das heraus. +- Eine globale Instanz trägt den Typ nur als JSON-Wert `"blockName":"divi/button"`. Dort kann ein Fremdwerkzeug + Buchstaben (`button`) oder den Schrägstrich (`divi\/button`, PHPs Standard von `json_encode`; `divi/button`) + kodieren. Buchstaben deckt wie bei `PresetUsageQuery` `REGEXP '\\u00[4-7][0-9a-fA-F]'` ab, den Schrägstrich ein + zweites `REGEXP`. `LibraryUsageQuery` und `PresetUsageQuery` zeigen das Muster (Ruling P23). + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php`: + +```php +' . $inner + . ''; + } + + public function testCandidatesContainTheNameAnEscapedSlashOrAnEscapedLetter(): void + { + $db = Mockery::mock(\wpdb::class); + $db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%divi/button%', + 'divi(\\\\/|\\\\u002[fF])button', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + self::assertSame('(prepared)', (new ModuleUsageQuery('divi/button'))->candidateCondition($db)); + } + + public function testNeedsAFullBlockName(): void + { + $this->expectException(InvalidArgumentException::class); + + new ModuleUsageQuery('button'); + } + + public function testMatchesBlocksOfTheModule(): void + { + $query = new ModuleUsageQuery('divi/button'); + + self::assertTrue($query->matches(self::page(''))); + self::assertTrue($query->matches(self::page(''))); + self::assertTrue($query->matches(self::page( + '' + ))); + self::assertFalse($query->matches(self::page(''))); + self::assertFalse($query->matches(self::page('')), 'another module name'); + self::assertFalse($query->matches('

Kein Divi-Inhalt

')); + } + + public function testGlobalInstancesOfTheModuleCountSoThatTheyAreReportedAsSkipped(): void + { + $query = new ModuleUsageQuery('divi/button'); + + self::assertTrue($query->matches(self::page( + '' + ))); + self::assertTrue($query->matches(self::page( + '' + )), 'an escaped slash decodes to the same name'); + self::assertFalse($query->matches(self::page( + '' + ))); + self::assertTrue($query->matches(self::page( + '' + )), 'Divi 4 conversion: the block itself carries globalModule'); + self::assertTrue($query->matches(self::page('')), 'unreadable attributes'); + } + + public function testBrokenMarkupCountsAsUse(): void + { + self::assertTrue((new ModuleUsageQuery('divi/button'))->matches( + '' + )); + } +} +``` + +Anhängen an `tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php` (vor der schließenden `}` der Klasse; die +Konstante kommt zu den vorhandenen am Klassenanfang): + +```php + private const TEXT = ''; + + private static function groupsOf(string $module): array + { + return $module === 'divi/text' ? ['content.decoration.bodyFont' => 'divi/font-body'] : []; + } + + public function testDefaultGroupPresetReachesModulesWithTheGroup(): void + { + $matcher = new PresetUsageMatcher(groupsOf: self::groupsOf(...)); + $group = new PresetScope('group', 'divi/font-body'); + $own = '{"groupPreset":{"content.decoration.bodyFont":{"presetId":["%s"],"groupName":"divi/font-body"}}}'; + + self::assertTrue($matcher->uses(sprintf(self::TEXT, '{}'), $group, 'gggggggggg', true)); + self::assertTrue( + $matcher->uses(sprintf(self::TEXT, sprintf($own, 'default')), $group, 'gggggggggg', true), + 'an entry with the default ID is no own preset' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, sprintf($own, 'hhhhhhhhhh')), $group, 'gggggggggg', true), + 'an own group preset replaces the default' + ); + self::assertFalse( + $matcher->uses(self::page('{}'), $group, 'gggggggggg', true), + 'a button has no font-body group' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, '{}'), new PresetScope('group', 'divi/spacing'), 'gggggggggg', true), + 'the text module has no spacing group in this fixture' + ); + self::assertFalse( + $matcher->uses(sprintf(self::TEXT, '{}'), $group, 'gggggggggg', false), + 'a preset that is not the default needs an explicit entry' + ); + } + + public function testDefaultGroupPresetIsNotDetectedWithoutGroupsOf(): void + { + self::assertFalse((new PresetUsageMatcher())->uses( + sprintf(self::TEXT, '{}'), + new PresetScope('group', 'divi/font-body'), + 'gggggggggg', + true + )); + } + + public function testGlobalInstancesDoNotReachADefaultGroupPreset(): void + { + $matcher = new PresetUsageMatcher(groupsOf: static fn (string $module): array => [ + 'content.decoration.bodyFont' => 'divi/font-body', + ]); + $instance = ''; + + self::assertFalse($matcher->uses($instance, new PresetScope('group', 'divi/font-body'), 'gggggggggg', true)); + } +``` + +Anhängen an `tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`: + +```php + public function testADefaultGroupPresetLooksForEveryDiviBlock(): void + { + $db = Mockery::mock(\wpdb::class); + $db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s)', + '%gggggggggg%', + '%wp:divi/%', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new PresetUsageQuery([ + ['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => true], + ]); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + } + + public function testWithGroupsDetectsTheImplicitUseOfADefaultGroupPreset(): void + { + $query = PresetUsageQuery::withGroups( + [['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => true]], + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [] + ); + + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + } +``` + +In `tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`, `testPresetInUseIsRefused()`, nach der Zeile +`self::assertStringContainsString('lit/divi-design-detach', $error['hint']);` ergänzen: + +```php + self::assertStringNotContainsString('4b', $error['hint'], 'the tool exists now'); +``` + +In `tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php` nach `testChangingAndMakingDefault()` ergänzen +(sollte der Composer ein Gruppen-Preset ohne `attrs` ablehnen, `'attrs'` wie in `testChangingAndMakingDefault` ergänzen): + +```php + /** R11: a default group preset no longer warns that pages are "not listed" – the dry run lists them. */ + public function testDefaultGroupPresetHasNoNotListedWarning(): void + { + $dry = self::assertToolError($this->ability()->execute(['items' => [[ + 'group' => 'font-body', 'preset' => 'Lesetext', 'rename' => 'Lesetext neu', 'make_default' => true, + ]]]), 'confirm_required'); + + self::assertSame([], $dry['warnings']); + } +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `composer test -- --filter 'ModuleUsageQueryTest|PresetUsageMatcherTest|PresetUsageQueryTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Design\ModuleUsageQuery" not found`; `Unknown named parameter $groupsOf`; +`Call to undefined method …PresetUsageQuery::withGroups()`; Mockery `No matching handler found for …::prepare(…)` für den +Standard-Gruppen-Test. + +Run: `composer test -- --filter 'DiviPresetsDeleteAbilityTest::testPresetInUseIsRefused|DiviPresetsSetAbilityTest::testDefaultGroupPresetHasNoNotListedWarning'` +Expected: FAIL – `Failed asserting that '… (ab Etappe 4b) …' does not contain "4b"` bzw. `Failed asserting that +['Standard-Gruppen-Presets wirken zusätzlich …'] is identical to []`. + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Design/ModuleUsageQuery.php`: + +```php +prepare( + '(post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%' . $db->esc_like($this->module) . '%', + $this->escapedSlash(), + self::ESCAPED_LETTER + ); + } + + public function matches(string $content): bool + { + try { + $index = NodeIndex::build((new Parser())->parse($content)); + } catch (MalformedMarkup) { + return true; + } + + foreach ($index->entries() as $entry) { + if (GlobalInstance::effectiveName($entry['block']) === $this->module) { + return true; + } + } + + return false; + } + + /** Regular expression for "divi\/button" and "divi/button" (two literal backslashes in the pattern). */ + private function escapedSlash(): string + { + [$namespace, $name] = explode('/', $this->module, 2); + + return $namespace . '(\\\\/|\\\\u002[fF])' . $name; + } +} +``` + +`src/Integrations/Divi/Design/PresetUsageMatcher.php` – vollständig ersetzen: + +```php +.presetId whose groupName is the group; the default + * group preset additionally reaches every block whose module has the group (groupsOf) and that has no own group + * preset for it (ruling R11) – without $groupsOf only the explicit references are found; + * - markup the parser rejects counts as a use. + * Global instances (opaque) get their content from the library entry, so they never reach a default preset. + * Pure PHP. + */ +final class PresetUsageMatcher +{ + /** Preset IDs that mean "no own preset" (GlobalPreset::is_preset_id_as_default). */ + public const DEFAULT_IDS = ['', 'default', '_initial']; + + /** @param (Closure(string $module): array)|null $groupsOf groupId => groupName of a module */ + public function __construct(private Parser $parser = new Parser(), private ?Closure $groupsOf = null) + { + } + + public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool + { + try { + $index = NodeIndex::build($this->parser->parse($content)); + } catch (MalformedMarkup) { + return true; + } + + $groups = []; + foreach ($index->entries() as $entry) { + if ($this->usesIn($entry['block'], $scope, $id, $isDefault, $groups)) { + return true; + } + } + + return false; + } + + /** @param array> $groups groupsOf() per module name, filled on demand */ + private function usesIn(Block $block, PresetScope $scope, string $id, bool $isDefault, array &$groups): bool + { + if ($scope->kind === PresetScope::MODULE) { + return $this->usesModulePreset($block, $scope, $id, $isDefault); + } + + return $this->usesGroupPreset($block->attrs(), $scope, $id) + || ($isDefault && $this->reachesByDefault($block, $scope, $groups)); + } + + private function usesModulePreset(Block $block, PresetScope $scope, string $id, bool $isDefault): bool + { + if ($block->name() !== $scope->name) { + return false; + } + + $stack = NodeSummary::presetIds($block); + if (in_array($id, $stack, true)) { + return true; + } + + return $isDefault && array_diff($stack, self::DEFAULT_IDS) === []; + } + + /** @param array $attrs */ + private function usesGroupPreset(array $attrs, PresetScope $scope, string $id): bool + { + $groups = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : []; + foreach ($groups as $group) { + if (! is_array($group) || ($group['groupName'] ?? null) !== $scope->name) { + continue; + } + if (in_array($id, (array) ($group['presetId'] ?? []), true)) { + return true; + } + } + + return false; + } + + /** + * Ruling R11: the module of $block has the group $scope and $block has no own group preset for its groupId. + * + * @param array> $groups + */ + private function reachesByDefault(Block $block, PresetScope $scope, array &$groups): bool + { + if ($this->groupsOf === null || ! $block->isDivi() || $block->isOpaque()) { + return false; + } + + $groups[$block->name()] ??= ($this->groupsOf)($block->name()); + $own = is_array($block->attrs()['groupPreset'] ?? null) ? $block->attrs()['groupPreset'] : []; + foreach ($groups[$block->name()] as $groupId => $groupName) { + if ($groupName === $scope->name && ! self::hasOwnPreset($own[$groupId] ?? null)) { + return true; + } + } + + return false; + } + + private static function hasOwnPreset(mixed $entry): bool + { + if (! is_array($entry)) { + return false; + } + + $ids = array_filter((array) ($entry['presetId'] ?? []), 'is_string'); + + return array_diff($ids, self::DEFAULT_IDS) !== []; + } +} +``` + +`src/Integrations/Divi/Design/PresetUsageQuery.php` – Klassen-Docblock, `withGroups()` und `candidateCondition()`. + +Vorher: + +```php + * The posts that use any of the presets $targets (GlobalUsages::reportFor()): candidates contain one of the IDs, a + * JSON escape of a letter (an escaped ID or key, fail closed like ruling P23) or – for a default module preset – a + * block of the module; PresetUsageMatcher decides per target. + */ +final class PresetUsageQuery implements UsageQuery +{ +``` + +Nachher: + +```php + * The posts that use any of the presets $targets (GlobalUsages::reportFor()): candidates contain one of the IDs, a + * JSON escape of a letter (an escaped ID or key, fail closed like ruling P23), for a default module preset a block + * of the module and for a default group preset any Divi block (ruling R11: which modules have the group is only + * known to PresetUsageMatcher::$groupsOf); PresetUsageMatcher decides per target. + */ +final class PresetUsageQuery implements UsageQuery +{ +``` + +Direkt nach dem Konstruktor (vor `candidateCondition()`) einfügen (`use Closure;` zu den Imports ergänzen): + +```php + /** + * Targets whose default group presets are also found where a module reaches them implicitly. + * + * @param list $targets + * @param Closure(string $module): array $groupsOf groupId => groupName of a module + */ + public static function withGroups(array $targets, Closure $groupsOf): self + { + return new self($targets, new PresetUsageMatcher(groupsOf: $groupsOf)); + } +``` + +In `candidateCondition()` vorher: + +```php + if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) { + $likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%'; + } +``` + +nachher: + +```php + if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) { + $likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%'; + } elseif ($target['default']) { + $likes[] = '%' . $db->esc_like('wp:divi/') . '%'; + } +``` + +`src/Integrations/Divi/Design/DiviPresetsSetAbility.php`: + +1. Konstante entfernen (vorher): + +```php + private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser ' + . 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.'; + +``` + +nachher: ersatzlos gestrichen (samt Leerzeile). + +2. In `plan()` vorher: + +```php + $targets = []; + $created = []; + $warnings = []; + foreach ($items as $index => $item) { +``` + +nachher: + +```php + $targets = []; + $created = []; + $groupsOf = $this->writer->groupsOf(...); + foreach ($items as $index => $item) { +``` + +3. Vorher: + +```php + $book = $book->withDefault($scope, $id); + if ($scope->kind === PresetScope::GROUP) { + $warnings[] = self::GROUP_DEFAULT_WARNING; + } + } elseif +``` + +nachher: + +```php + $book = $book->withDefault($scope, $id); + } elseif +``` + +4. Ende von `plan()` vorher: + +```php + $targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)), + array_values(array_unique($warnings)) + ); +``` + +nachher: + +```php + $targets === [] ? null : $this->usages->reportFor(PresetUsageQuery::withGroups($targets, $groupsOf)) + ); +``` + +5. Klassen-Docblock: `The dry run lists the posts that use a changed preset or reach a new default.` → +`The dry run lists the posts that use a changed preset or reach a new default, also implicitly (ruling R11).` + +`src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php`: + +1. Klassen-Docblock vorher: `with its usages (in_use); lit/divi-design-detach (Etappe 4b) removes the references first. Only explicit references` +nachher: `with its usages (in_use); lit/divi-design-detach removes the references first. Only explicit references` + +2. Konstante vorher: + +```php + private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach (ab Etappe 4b) oder im ' + . 'Divi-Builder; danach erneut löschen.'; +``` + +nachher: + +```php + private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach oder im Divi-Builder; ' + . 'danach erneut löschen.'; +``` + +3. In `plan()` vorher: + +```php + $defaults = array_values(array_filter($targets, static fn (array $target): bool => $target['default'])); + + return new DesignPlan( + $book->data(), + $diff, + PresetDiff::summary($diff), + $defaults === [] ? null : $this->usages->reportFor(new PresetUsageQuery($defaults)), +``` + +nachher: + +```php + $defaults = array_values(array_filter($targets, static fn (array $target): bool => $target['default'])); + $groupsOf = $this->writer->groupsOf(...); + + return new DesignPlan( + $book->data(), + $diff, + PresetDiff::summary($diff), + $defaults === [] ? null : $this->usages->reportFor(PresetUsageQuery::withGroups($defaults, $groupsOf)), +``` + +(Die Prüfung der ausdrücklichen Verweise `new PresetUsageQuery($explicit)` bleibt: alle Ziele dort haben `default => false`.) + +`src/Integrations/Divi/Design/DiviPresetGetAbility.php` vorher: + +```php + $usage = $this->usages->reportFor( + new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => $isDefault]]), + false + ); +``` + +nachher: + +```php + $usage = $this->usages->reportFor( + PresetUsageQuery::withGroups( + [['scope' => $scope, 'id' => $id, 'default' => $isDefault]], + $this->writer->groupsOf(...) + ), + false + ); +``` + +`src/Integrations/Divi/DiviIntegration.php` bleibt unverändert: Die drei Tools bekommen `$this->designWriter` bereits +injiziert und beziehen `groupsOf` daraus (kein neuer Divi-Aufruf, `DiviBoundaryTest` unberührt). `docs/guides/*` und +`PresetArea` enthalten den Hinweis „nicht aufgelistet“ nicht; zur Kontrolle: + +Run: `grep -rn "nicht aufgelistet\|ab Etappe 4b\|Standard-Gruppen-Presets wirken" src docs/guides` +Expected: keine Ausgabe. + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `composer test -- --filter 'ModuleUsageQueryTest|PresetUsageMatcherTest|PresetUsageQueryTest|DiviPresetsDeleteAbilityTest|DiviPresetsSetAbilityTest|DiviPresetGetAbilityTest'` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/ModuleUsageQuery.php src/Integrations/Divi/Design/PresetUsageMatcher.php src/Integrations/Divi/Design/PresetUsageQuery.php src/Integrations/Divi/Design/DiviPresetsSetAbility.php src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php src/Integrations/Divi/Design/DiviPresetGetAbility.php tests/Unit/Integrations/Divi/Design/ModuleUsageQueryTest.php tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php +git commit -m "feat: Fundstellen aller Module eines Typs, Standard-Gruppen-Presets in der Fundstellensuche (ModuleUsageQuery)" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- +### Task 7: Umschreib-Strategien – `NodeRewrite`, `TreeRewrite`, `AssignRewrite`, `DetachRewrite` + +**Files:** +- Create: `src/Integrations/Divi/Design/NodeRewrite.php` +- Create: `src/Integrations/Divi/Design/TreeRewrite.php` +- Create: `src/Integrations/Divi/Design/AssignRewrite.php` +- Create: `src/Integrations/Divi/Design/DetachRewrite.php` +- Test: `tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php` + +**Interfaces:** +- Consumes: `Block` (`name()`, `attrs()`, `setAttrs()`, `isOpaque()`, `isDivi()`, `isGlobal()`, `children()`), `NodeIndex::entries()`, + `NodeSummary::label()/globalRef()/presetIds()`, `GlobalInstance::effectiveName()`, `Json::encode()`, + `PresetUsageMatcher::DEFAULT_IDS` (Task 6), `PresetScope`, `PresetAssignment` (Task 3: `apply()`, `clearOverrides()`, + `overrides()`, `names()`), `PresetOverrides::describe()` (Task 2). +- Produces (Signaturen wie im Gerüst): + - `interface NodeRewrite { rewrite(Block): ?array; notes(Block, string $nodeId): array; concernsInstance(Block): bool }` + - `final class TreeRewrite { __construct(NodeRewrite); apply(NodeIndex): array{changed, warnings, skipped} }` – ruft für + jeden nicht opaken Eintrag `rewrite()`, bei Änderung `setAttrs()` und danach `notes()`; opake Blöcke nie (weder + `rewrite()` noch `setAttrs()`), sondern bei `concernsInstance()` als `skipped`. Gründe: „globales Modul – im + Bibliothekseintrag ändern“ (`NodeSummary::globalRef()`), „globales Modul ohne Bibliothekseintrag – im + Divi-Builder prüfen“, „Attribute nicht lesbar – im Divi-Builder prüfen“. + - `final class AssignRewrite implements NodeRewrite` – nur Blöcke mit `name() === $module`; `onlyWithoutPreset` (Stapel + leer oder nur `DEFAULT_IDS`) und `onlyWithPresetId` (ID im Stapel) schränken ein (beide gesetzt: beide müssen gelten); + neue Attribute aus `PresetAssignment::apply($module, $attrs, $spec, 'node')` (Fehler dort = `LogicException`: die + Spezifikation wurde vorher geprüft), danach bei `clearOverrides` `PresetAssignment::clearOverrides()`; unveränderte + Attribute (JSON-Vergleich) → `null` (idempotent). `notes()`: `overrides()->describe()` mit `names()`, nur wenn nicht + `clearOverrides`. `concernsInstance()`: `GlobalInstance::effectiveName() === $module`. + - `final class DetachRewrite implements NodeRewrite` – Modul-Bereich: ID aus dem `modulePreset`-Stapel von Blöcken des + Moduls (leerer Stapel → Schlüssel entfällt; Altform als String wird erkannt); Gruppen-Bereich: ID aus jedem + `groupPreset`-Eintrag mit passendem `groupName` (leerer Eintrag, dann leere Map entfällt); nur Divi-Blöcke. + `concernsInstance()`: die ID kommt im JSON der Instanz-Attribute oder ihrer Kinder-Schnappschüsse vor. + +**Entscheidungen:** +- `notes()` wird von `TreeRewrite` erst nach `setAttrs()` aufgerufen und liest deshalb die neuen Attribute + (`$block->attrs()`); die Filter von `AssignRewrite` werden dort nicht erneut geprüft (sonst fiele `onlyWithoutPreset` + nach der Zuweisung durch). +- `AssignRewrite::concernsInstance()` prüft nur den Modultyp (die lokalen Attribute einer Instanz sind opak; + im Zweifel lieber ein Hinweis zu viel als eine stille Auslassung, fail closed wie bei `ModuleUsageQuery`). +- Gleichheit von Attributen: `Json::encode()` beider Arrays (PHPs `===` vergleicht `stdClass`-Werte nach Identität). + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php`: + +```php +parse( + '' . $inner + . '' + ); + } + + /** Marks every divi/button; refuses opaque blocks, like the real strategies must never be offered one. */ + private static function touchButtons(): NodeRewrite + { + return new class implements NodeRewrite { + public function rewrite(Block $block): ?array + { + if ($block->isOpaque()) { + throw new \LogicException('An opaque block was offered to rewrite().'); + } + + return $block->name() === 'divi/button' ? $block->attrs() + ['touched' => true] : null; + } + + public function notes(Block $block, string $nodeId): array + { + return ['Hinweis ' . $nodeId]; + } + + public function concernsInstance(Block $instance): bool + { + return GlobalInstance::effectiveName($instance) === 'divi/button'; + } + }; + } + + public function testChangesBlocksAndReportsThem(): void + { + $document = self::document( + '' + . '' + ); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame([ + ['id' => 's0.r0.c0.m0', 'module' => 'divi/button', 'label' => 'Kaufen'], + ['id' => 's0.r0.c0.m2', 'module' => 'divi/button', 'label' => ''], + ], $result['changed']); + self::assertSame(['Hinweis s0.r0.c0.m0', 'Hinweis s0.r0.c0.m2'], $result['warnings']); + self::assertSame([], $result['skipped']); + self::assertSame(2, substr_count(Serializer::document($document), '"touched":true')); + } + + public function testNothingToDoChangesNothing(): void + { + $document = self::document(''); + $before = Serializer::document($document); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame(['changed' => [], 'warnings' => [], 'skipped' => []], $result); + self::assertSame($before, Serializer::document($document)); + } + + public function testGlobalInstancesAreSkippedWithTheLibraryEntry(): void + { + $document = self::document( + '' + . '' + ); + $before = Serializer::document($document); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame([], $result['changed']); + self::assertSame( + [['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – im Bibliothekseintrag 1058 ändern']], + $result['skipped'] + ); + self::assertSame($before, Serializer::document($document), 'instances are never rewritten'); + } + + public function testBlocksWithUnreadableAttributesAreSkippedToo(): void + { + $document = self::document(''); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame( + [['id' => 's0.r0.c0.m0', 'reason' => 'Attribute nicht lesbar – im Divi-Builder prüfen']], + $result['skipped'] + ); + } + + public function testAGlobalLayoutWithoutLibraryEntryHasItsOwnReason(): void + { + $document = self::document(''); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame( + [['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen']], + $result['skipped'] + ); + } +} +``` + +`tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php`: + +```php + 'divi/button', 'preset' => 'aaaaaaaaaa']; + + /** PresetBookTest::stored() with settings in "CTA primär" (aaaaaaaaaa): background colour #111111. */ + private static function stored(): array + { + $stored = PresetBookTest::stored(); + $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = [ + 'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#111111']]]]], + ]; + + return $stored; + } + + private static function assignment(): PresetAssignment + { + return new PresetAssignment( + static fn (): PresetBook => PresetBook::fromDivi(self::stored()), + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [], + static fn (string $id): bool => preg_match('/^[a-z0-9]{10}$/D', $id) === 1 + ); + } + + /** @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … in the order of $inner. */ + private static function index(string $inner): NodeIndex + { + return NodeIndex::build((new Parser())->parse( + '' . $inner + . '' + )); + } + + private static function node(NodeIndex $index, string $id): Block + { + $block = $index->find('s0.r0.c0.' . $id); + self::assertNotNull($block); + + return $block; + } + + public function testAssignsTheStackToBlocksOfTheModuleOnly(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => ['aaaaaaaaaa', 'bbbbbbbbbb']]); + + $button = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb'], $button['modulePreset'] ?? null); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another module'); + } + + public function testOnlyWithoutPresetSkipsBlocksWithAnExplicitPreset(): void + { + $index = self::index( + '' + . '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithoutPreset: true); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm1'))['modulePreset'] ?? null); + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm2'))['modulePreset'] ?? null); + } + + public function testOnlyWithPresetIdReplacesThatPreset(): void + { + $index = self::index( + '' + . '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithPresetId: 'bbbbbbbbbb'); + + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm0'))['modulePreset'] ?? null); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another preset'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'no preset at all'); + } + + public function testBothFiltersMustHold(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite( + 'divi/button', + self::assignment(), + self::BUTTON, + onlyWithoutPreset: true, + onlyWithPresetId: 'bbbbbbbbbb' + ); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + } + + public function testRunningAgainChangesNothing(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + } + + public function testGroupPresetsAreAssignedThroughTheKeyOfTheGroup(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite( + 'divi/text', + self::assignment(), + ['group_presets' => ['font-body' => 'Lesetext']] + ); + + $attrs = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame( + ['presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body'], + $attrs['groupPreset']['content.decoration.bodyFont'] ?? null + ); + self::assertArrayNotHasKey('modulePreset', $attrs); + } + + public function testNotesDescribeWhatTheAssignedPresetCovers(): void + { + $index = self::index( + '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + $different = self::node($index, 'm0'); + $different->setAttrs($rewrite->rewrite($different) ?? []); + $notes = $rewrite->notes($different, 's0.r0.c0.m0'); + + self::assertCount(1, $notes); + self::assertStringContainsString('s0.r0.c0.m0: lokale Werte überdecken „CTA primär“', $notes[0]); + self::assertStringContainsString('clear_overrides: true entfernt sie.', $notes[0]); + + $same = self::node($index, 'm1'); + $same->setAttrs($rewrite->rewrite($same) ?? []); + self::assertSame([], $rewrite->notes($same, 's0.r0.c0.m1'), 'the same value covers nothing'); + } + + public function testClearOverridesRemovesTheCoveredValuesAndHasNoNotes(): void + { + $index = self::index( + '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true); + $block = self::node($index, 'm0'); + + $attrs = $rewrite->rewrite($block); + + self::assertSame(['aaaaaaaaaa'], $attrs['modulePreset'] ?? null); + self::assertArrayNotHasKey('button', $attrs, 'the local value and its empty parents are gone'); + $block->setAttrs($attrs); + self::assertSame([], $rewrite->notes($block, 's0.r0.c0.m0')); + } + + public function testClearOverridesAloneAlsoChangesABlockThatAlreadyHasThePreset(): void + { + $index = self::index( + '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true); + + $attrs = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame(['modulePreset' => ['aaaaaaaaaa']], $attrs); + } + + public function testAnUnvalidatedSpecIsALogicError(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => 'Gibt es nicht']); + + $this->expectException(LogicException::class); + + $rewrite->rewrite(self::node($index, 'm0')); + } + + public function testGlobalInstancesOfTheModuleConcernTheRewrite(): void + { + $index = self::index( + '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0'))); + self::assertFalse($rewrite->concernsInstance(self::node($index, 'g1'))); + } +} +``` + +`tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php`: + +```php +parse( + '' . $inner + . '' + )); + } + + private static function node(NodeIndex $index, string $id): Block + { + $block = $index->find('s0.r0.c0.' . $id); + self::assertNotNull($block); + + return $block; + } + + public function testRemovesAModulePresetFromTheStack(): void + { + $index = self::index( + '' + . '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'); + + self::assertSame(['modulePreset' => ['bbbbbbbbbb']], $rewrite->rewrite(self::node($index, 'm0'))); + self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'an empty stack leaves no key'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'another module'); + self::assertSame([], $rewrite->rewrite(self::node($index, 'm3')), 'legacy string form'); + self::assertNull($rewrite->rewrite(self::node($index, 'm4')), 'the ID is not in the stack'); + } + + public function testRemovesAGroupPresetFromEveryEntryOfTheGroup(): void + { + $index = self::index( + '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg'); + + $first = $rewrite->rewrite(self::node($index, 'm0')); + self::assertSame(['hhhhhhhhhh'], $first['groupPreset']['content.decoration.bodyFont']['presetId'] ?? null); + self::assertSame( + ['presetId' => ['gggggggggg'], 'groupName' => 'divi/spacing'], + $first['groupPreset']['module.decoration.spacing'] ?? null, + 'another group keeps its preset' + ); + self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'empty entry and map are removed'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'the ID sits in another group'); + self::assertNull($rewrite->rewrite(self::node($index, 'x0')), 'not a Divi block'); + } + + public function testAnUntouchedBlockStaysNull(): void + { + $index = self::index(''); + + self::assertNull( + (new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'))->rewrite(self::node($index, 'm0')) + ); + self::assertNull( + (new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg'))->rewrite(self::node($index, 'm1')) + ); + } + + public function testNotesAreEmpty(): void + { + $index = self::index(''); + + self::assertSame( + [], + (new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa')) + ->notes(self::node($index, 'm0'), 's0.r0.c0.m0') + ); + } + + public function testInstancesThatMentionTheIdConcernTheRewrite(): void + { + $index = self::index( + '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'); + $other = new DetachRewrite(new PresetScope('module', 'divi/button'), 'bbbbbbbbbb'); + + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0')), 'localAttrs'); + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g1')), 'snapshot of a child'); + self::assertFalse($other->concernsInstance(self::node($index, 'g0'))); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `composer test -- --filter 'TreeRewriteTest|AssignRewriteTest|DetachRewriteTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Design\NodeRewrite" not found` (bzw. `AssignRewrite`/`DetachRewrite` not found). + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Design/NodeRewrite.php`: + +```php + + */ + public function notes(Block $block, string $nodeId): array; + + /** Whether the opaque global instance $instance would matter for this change (then it is reported as skipped). */ + public function concernsInstance(Block $instance): bool; +} +``` + +`src/Integrations/Divi/Design/TreeRewrite.php`: + +```php +, warnings: list, + * skipped: list} + */ + public function apply(NodeIndex $index): array + { + $changed = []; + $warnings = []; + $skipped = []; + foreach ($index->entries() as $entry) { + $id = $entry['id']; + $block = $entry['block']; + + if ($block->isOpaque()) { + if ($this->rewrite->concernsInstance($block)) { + $skipped[] = ['id' => $id, 'reason' => self::reason($block)]; + } + continue; + } + + $attrs = $this->rewrite->rewrite($block); + if ($attrs === null) { + continue; + } + + $block->setAttrs($attrs); + $changed[] = ['id' => $id, 'module' => $block->name(), 'label' => NodeSummary::label($block)]; + array_push($warnings, ...$this->rewrite->notes($block, $id)); + } + + return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped]; + } + + private static function reason(Block $block): string + { + $ref = NodeSummary::globalRef($block); + if ($ref !== null) { + return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']); + } + + return $block->isGlobal() + ? 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen' + : 'Attribute nicht lesbar – im Divi-Builder prüfen'; + } +} +``` + +`src/Integrations/Divi/Design/AssignRewrite.php`: + +```php +selects($block)) { + return null; + } + + $attrs = $block->attrs(); + $result = $this->assignment->apply($this->module, $attrs, $this->spec, 'node'); + if ($result['errors'] !== []) { + throw new LogicException('The preset assignment was not validated: ' . implode('; ', $result['errors'])); + } + + $new = $this->clearOverrides ? $this->assignment->clearOverrides($result['attrs']) : $result['attrs']; + + return self::same($attrs, $new) ? null : $new; + } + + public function notes(Block $block, string $nodeId): array + { + if ($this->clearOverrides) { + return []; + } + + return $this->assignment->overrides()->describe($nodeId, $block->attrs(), $this->assignment->names()); + } + + public function concernsInstance(Block $instance): bool + { + return GlobalInstance::effectiveName($instance) === $this->module; + } + + private function selects(Block $block): bool + { + if ($block->name() !== $this->module) { + return false; + } + + $stack = NodeSummary::presetIds($block); + if ($this->onlyWithoutPreset && array_diff($stack, PresetUsageMatcher::DEFAULT_IDS) !== []) { + return false; + } + + return $this->onlyWithPresetId === null || in_array($this->onlyWithPresetId, $stack, true); + } + + /** @param array $before @param array $after */ + private static function same(array $before, array $after): bool + { + try { + return Json::encode($before) === Json::encode($after); + } catch (JsonException) { + return false; + } + } +} +``` + +`src/Integrations/Divi/Design/DetachRewrite.php`: + +```php +isDivi()) { + return null; + } + + return $this->scope->kind === PresetScope::MODULE + ? $this->withoutModulePreset($block) + : $this->withoutGroupPreset($block->attrs()); + } + + public function notes(Block $block, string $nodeId): array + { + return []; + } + + public function concernsInstance(Block $instance): bool + { + return self::mentions($instance, $this->id); + } + + /** @return array|null */ + private function withoutModulePreset(Block $block): ?array + { + $attrs = $block->attrs(); + $stack = self::idList($attrs['modulePreset'] ?? null); + if ($block->name() !== $this->scope->name || ! in_array($this->id, $stack, true)) { + return null; + } + + $rest = $this->without($stack); + if ($rest === []) { + unset($attrs['modulePreset']); + } else { + $attrs['modulePreset'] = $rest; + } + + return $attrs; + } + + /** + * @param array $attrs + * @return array|null + */ + private function withoutGroupPreset(array $attrs): ?array + { + $groups = $attrs['groupPreset'] ?? null; + if (! is_array($groups)) { + return null; + } + + $changed = false; + foreach ($groups as $groupId => $entry) { + if (! is_array($entry) || ($entry['groupName'] ?? null) !== $this->scope->name) { + continue; + } + $ids = self::idList($entry['presetId'] ?? null); + if (! in_array($this->id, $ids, true)) { + continue; + } + + $changed = true; + $rest = $this->without($ids); + if ($rest === []) { + unset($groups[$groupId]); + } else { + $groups[$groupId]['presetId'] = $rest; + } + } + + if (! $changed) { + return null; + } + + if ($groups === []) { + unset($attrs['groupPreset']); + } else { + $attrs['groupPreset'] = $groups; + } + + return $attrs; + } + + /** @return list The IDs of a stored stack: a list, the legacy single string, else none. */ + private static function idList(mixed $value): array + { + if (is_string($value)) { + return [$value]; + } + + return is_array($value) ? array_values($value) : []; + } + + /** + * @param array $ids + * @return list + */ + private function without(array $ids): array + { + return array_values(array_filter($ids, fn (mixed $item): bool => $item !== $this->id)); + } + + /** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */ + private static function mentions(Block $block, string $id): bool + { + try { + if (str_contains(Json::encode($block->attrs()), $id)) { + return true; + } + } catch (JsonException) { + return true; + } + + foreach ($block->children() as $child) { + if (self::mentions($child, $id)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `composer test -- --filter 'TreeRewriteTest|AssignRewriteTest|DetachRewriteTest'` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/NodeRewrite.php src/Integrations/Divi/Design/TreeRewrite.php src/Integrations/Divi/Design/AssignRewrite.php src/Integrations/Divi/Design/DetachRewrite.php tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php +git commit -m "feat: Umschreib-Strategien für Preset-Zuweisung und -Entkopplung (TreeRewrite, AssignRewrite, DetachRewrite)" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- +### Offene Punkte + +1. **Verdrahtung `groupsOf` (Task 6):** `PresetUsageMatcher`/`PresetUsageQuery` werden nicht in `DiviIntegration`, sondern in + den drei Abilities (`DiviPresetGetAbility`, `DiviPresetsSetAbility`, `DiviPresetsDeleteAbility`) gebaut. Der Plan lässt + `DiviIntegration` unverändert und nimmt `groupsOf` aus dem dort schon injizierten `DesignSystemWriter` + (`PresetUsageQuery::withGroups()`); Konstruktoren und Ability-Tests bleiben damit stabil. Task 8–10 sollten für + `PresetUsageQuery` mit Standard-Gruppen-Presets (detach eines Standard-Gruppen-Presets) ebenfalls `withGroups()` nutzen. +2. **`DetachRewrite` und implizite Nutzung:** Es entfernt nur ausdrückliche Verweise (R10). Der Matcher mit `groupsOf` listet + im Probelauf aber auch implizite Treffer eines Standard-Gruppen-Presets; diese Seiten haben danach keine Änderung + (`PageRewriter` darf sie nicht als `remaining` zählen – nur Seiten mit nicht leerem `changed`). +3. **Opake Blöcke mit unlesbaren Attributen** (`ModuleUsageQuery`/`AssignRewrite`): Kinder eines Blocks mit unlesbarem JSON + werden vom `NodeIndex` nicht durchlaufen; steckt dort ein Button, wird er weder gefunden noch gemeldet. Gleiches galt + schon für 4a. Der Plan meldet nur den Block selbst (`effectiveName`), nicht dessen Nachkommen. +4. **`PresetAssignment`/`PresetOverrides` (Tasks 2–3 anderer Autoren):** Die Tests dieses Teils erwarten von `apply()` das + Format `groupPreset. = {presetId: [id], groupName}` und von `describe()` den R5-Anfang + `: lokale Werte überdecken „“` sowie, dass ein lokales Blatt mit gleichem Wert nichts meldet und + `clear()` leere Eltern entfernt (R4). Weichen die Implementierungen ab, sind die Erwartungen in `AssignRewriteTest` anzupassen. +5. **`testDefaultGroupPresetHasNoNotListedWarning`** setzt voraus, dass `PresetComposer` ein Gruppen-Preset mit nur + `rename` akzeptiert; sonst `attrs` ergänzen (im Test vermerkt). + +--- + +### Task 8: `PageRewriter` – Fundstellen umschreiben (Probelauf, höchstens 50 Seiten, Revision und Protokoll je Seite) + +> **Signatur-Änderungen gegenüber dem Gerüst (bitte in „Verbindliche Signaturen“ übernehmen):** +> 1. `PageRewriter::preview()` liefert zusätzlich **`blocked_hidden_count: int`** (immer vorhanden). Ohne das Feld +> erführe die Ability nicht, dass eine **nicht lesbare** Live-Fundstelle die Bestätigung blockiert +> (`UsageReport::$blockedHidden`, Audit L4 – solche Seiten werden nie mit ID genannt). Tasks 9/10 nennen es im +> Probelauf wie `GlobalEdits::blockedNote()`. +> 2. Neue öffentliche Konstante **`PageRewriter::NO_CHANGE`** (Grund für `post_ids`, die der Aufruf nicht ändert). +> 3. Factory **`DiviIntegration::pageRewriter(DiviPageLoader $loader): PageRewriter`** (privat, nimmt den Loader +> wie `writer($loader)`, damit `abilities()` eine Loader-Instanz teilt). Tasks 9/10 rufen +> `$this->pageRewriter($loader)` in `abilities()` auf. +> 4. `apply()`-Fehler `forbidden` trägt `blocked_ids` (und ggf. `blocked_hidden_count`) als Details. +> +> Konstruktor, `PAGE_LIMIT`, `preview()`/`apply()`-Parameter und alle übrigen Rückgabefelder bleiben wie im Gerüst. +> `DiviSaver` ist nicht final (Mock möglich), `DiviPageLoader` ist final – der Test nutzt den echten Loader +> (`LibraryFixtures::loader()`); `ThemeBuilderAccess` braucht der Umschreiber nicht (die Theme-Builder-Rechte prüfen +> schon `DiviPageLoader::loadWritable()` → `ThemeBuilderAccess::layout()` und `GlobalUsages::reportFor()`). + +**Files:** +- Create: `src/Integrations/Divi/Design/PageRewriter.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` (Import, private Factory `pageRewriter()`) +- Test: `tests/Unit/Integrations/Divi/Design/PageRewriterTest.php` + +**Interfaces:** +- Consumes: + - `GlobalUsages::reportFor(UsageQuery): UsageReport`, `GlobalUsages::rowsFor(UsageQuery): list`, + `GlobalUsages::report(int $libraryId): UsageReport`, `GlobalUsages::CONTENT_CHUNK`, `GlobalUsages::HARD_CAP` + (vorhanden; `GlobalUsages` ist nicht final → Mock). + - `UsageReport` (`count`, `hidden`, `live`, `blocked`, `blockedHidden`, `tooMany`, `exists()`, `blocks()`). + - `UsageQuery` (Interface; im Test ein Mock, in Tasks 9/10 `ModuleUsageQuery`/`PresetUsageQuery`). + - `DiviPageLoader::loadWritable(mixed): DiviPage|WP_Error`, `DiviPageLoader::library(): ?LibraryAccess`. + - `DiviPage` (`post`, `document`, `index`, `isLibraryItem()`, `isThemeBuilderLayout()`). + - `LibraryAccess::isGlobal(WP_Post): bool`. + - `TreeRewrite::__construct(NodeRewrite)`, `TreeRewrite::apply(NodeIndex): array{changed, warnings, skipped}` und + `interface NodeRewrite` (Task 7). + - `Serializer::document(Document): string`, `DiviSaver::save(WP_Post, string, SaveScope): array{revision_id, warnings}` + (wirft `WriteFailed`, `DiviLocked`), `SaveScope`. + - `ChangeLog::record()`, `ChangeLog::recordPartial()`, `FollowUp::run()`, `Errors::make()/unsupported()/partialWrite()/ + clientMessage()/codeOf()`, `Log::failure()`. + - WordPress: `current_user_can('read_post')`, `wp_revisions_enabled()`, `_get_non_cached_ids()`, `_prime_post_caches()`, + `wp_cache_delete()`, `do_action('lit_mcp_after_save')`. +- Produces: + - `class PageRewriter` (nicht final, damit die Ability-Tests aus Tasks 9/10 ihn mocken können) mit `public const PAGE_LIMIT = 50;`, `public const NO_CHANGE`, + `__construct(GlobalUsages $usages, DiviPageLoader $loader, DiviSaver $saver, ChangeLog $log)`, + `preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error` → + `array{pages: list, warnings:list}>, pages_count:int, nodes_count:int, skipped:list, blocked_ids:list, blocked_hidden_count:int, hidden_count:int}` (pages ≤ 50), + `apply(string $ability, UsageQuery $query, Closure $rewrite, ?array $postIds, string $summary): array|WP_Error` → + `array{saved: list, remaining:int, warnings:list, + skipped:list}`; `WP_Error`: `unsupported` (über `HARD_CAP`), `forbidden` + (blockiert, mit `blocked_ids`), `partial_write` (mit `saved`, `post_id`, ggf. `revision_id`). `WriteFailed`/`DiviLocked` + vor der ersten gespeicherten Seite werden geworfen (AbilityRegistrar bildet sie ab wie bei jedem Divi-Schreibzugriff). + - `DiviIntegration::pageRewriter(DiviPageLoader $loader): PageRewriter` (privat). + +**Entscheidungen dieses Tasks (mit Begründung):** + +1. **Blockierung und `HARD_CAP` (R1, Spec §6.6, wie `GlobalEdits::pagesRefusal()`):** Ohne `post_ids` gilt der ganze + `UsageReport`: `tooMany` → `unsupported` (Probelauf und `apply`), `blocks()` → Probelauf nennt `blocked_ids` / + `blocked_hidden_count`, `apply` antwortet `forbidden` und schreibt nichts. **Mit `post_ids`** zählen nur diese + Seiten: `blocked_ids` = Schnitt mit `post_ids`, `tooMany` lehnt nicht ab, `blocked_hidden_count` = 0. Begründung: + Die Alles-oder-nichts-Regel schützt davor, dass eine site-weite Änderung halb ankommt; wer ausdrücklich Seiten + wählt, ändert nur diese, und jede einzelne prüft `loadWritable()` ohnehin (`edit_post`, Theme-Builder-Rechte). So + kann ein Redakteur seine eigenen Seiten umstellen, obwohl fremde Live-Seiten dasselbe Modul nutzen; der Hinweis von + `tooMany` verweist genau darauf. +2. **`post_ids`:** filtert die Kandidaten aus `rowsFor()`. IDs, die der Aufruf nicht ändert und zu denen es keinen + anderen Grund gibt (kein Kandidat, nicht lesbar, nichts Passendes, bereits zugewiesen), landen in `skipped` mit dem + **einheitlichen** Text `NO_CHANGE` – der Client erfährt, dass seine ID nichts bewirkt hat, aber nicht, ob es den + Eintrag gibt (Audit L4/P12). +3. **Nicht lesbare Kandidaten** (`read_post` fehlt) werden nicht geladen und nie genannt; sie stehen nur als + `hidden_count` im Probelauf (aus dem Report). Lehnt `loadWritable()` mit `not_found` ab (versteckte Bibliothek, + unsichtbarer Theme Builder), gilt dasselbe. Jede andere Ablehnung (keine Bearbeitungsrechte bei lesbarer Seite, + Divi 4, kaputtes Markup, Builder für den Typ aus, verpasster Termin) → `skipped` mit der Meldung des Loaders (R8: + nicht `remaining`, Wiederholungen enden). +4. **Speicher-Scope wie `DiviWriter`:** Seite → `Page`; Theme-Builder-Layout → `ThemeBuilderLayout`; Bibliothekseintrag + → `LibraryItem`, wenn er weder global ist noch auf Seiten als Instanz erscheint, sonst `GlobalItem` (Audit L2). + Zusatzprüfungen statt `GlobalEdits::prepare()`/`ThemeBuilderEdits::prepare()`: + - **Form** (`LibraryContent::shapeViolation`): entfällt, weil der Umschreiber nur Attribute ändert (keine Knoten + entfernt/hinzufügt, Wurzeltyp bleibt). + - **P8a für Bibliothekseinträge:** Die Seiten mit Instanzen enthalten den Modultyp/die Preset-ID nicht und fehlen + daher im Haupt-Report. Für jeden sichtbaren Bibliothekseintrag mit Änderung prüft `GlobalUsages::report($id)`; blockiert + er (oder `tooMany`) → `skipped` mit Grund. Nicht die ganze Operation abbrechen: Der Eintrag ist nur eine von vielen + Fundstellen, und der Probelauf nennt ihn bereits. + - **Wiederherstellungspunkt (`VisibleEdits::backupRefusal`):** Der Umschreiber legt **keine** Sicherungskopien an + (PostCloner im Massenlauf, kses-Problem P9). Sind Revisionen für einen site-weit sichtbaren Bibliothekseintrag oder + ein Theme-Builder-Layout abgeschaltet (`wp_revisions_enabled()` false) → `skipped` mit Grund. Theme-Builder-Layouts + werden dabei immer so behandelt (ob eine Live-Vorlage sie zeigt, wird nicht geprüft – strenger und ohne zusätzliche + Vorlagen-Lesungen). Seiten bleiben wie bei `DiviWriter` ohne diese Prüfung. + - Die Bestätigung selbst kommt vom `confirm` der Ability (Tasks 9/10); `GlobalEdits`/`ThemeBuilderEdits` werden nicht + aufgerufen (sie verlangen ein eigenes `confirm` je Eintrag). +5. **Kosten (bis 2000 Live- bzw. `PARSE_CAP` = 5000 Kandidaten):** `remaining` und `pages_count` verlangen, jeden + Kandidaten umzurechnen. Kandidaten werden in Blöcken von `GlobalUsages::CONTENT_CHUNK` (50) mit + `_prime_post_caches()` geladen und die frisch geladenen Posts danach mit `wp_cache_delete()` verworfen (wie + `GlobalUsages::reportOfRows()`, Audit L1) – so bleiben Speicher und Abfragen begrenzt (≈ 1 Abfrage je 50 Posts + statt einer je Post, kein Anwachsen des Objekt-Caches). Ein Generator liefert die Änderungen nacheinander; `apply()` + speichert die ersten 50 und zählt den Rest nur. Wir verzichten auf eine frühere Obergrenze für die Zählung: Die Zahl + ist laut A6 verbindlich und der Aufwand ist durch `HARD_CAP`/`PARSE_CAP` ohnehin begrenzt. +6. **Teilerfolg:** Scheitert `DiviSaver::save()` (`WriteFailed`, `DiviLocked`) **nach** mindestens einer gespeicherten + Seite → `Errors::partialWrite()` mit `saved` (Revisionen), `post_id` der gescheiterten Seite und ggf. deren + `revision_id`; die gespeicherten Seiten haben ihren Protokolleintrag. Konnte der Saver die gescheiterte Seite nicht + zurücksetzen (`! rolledBack`) → `ChangeLog::recordPartial()` mit dem Text von `DiviWriter`. **Vor** der ersten Seite + wird die Ausnahme geworfen (`DiviLocked` → Freigabe-Fehler, `WriteFailed` → `write_failed`/`partial_write` durch den + AbilityRegistrar) – Verhalten wie jedes Divi-Schreibwerkzeug. +7. **Nachlauf je Seite** wie `PostSaveFollowUp`: `lit_mcp_after_save` (Eingabe `['post_id' => …]`) und + `ChangeLog::record($ability, post_type, (string) $id, 'Divi "": ', revision_id)` jeweils über + `FollowUp::run()` – ein Fehler dort wird Warnung, die Seite bleibt gespeichert. Warnungen in `apply()` tragen das + Präfix `Eintrag : ` (Überdeckungen aus `TreeRewrite`, Hinweise des Savers wie `CHANGED_ON_SAVE`). +8. **Globale Instanzen (R8):** `TreeRewrite` meldet sie in `skipped` mit Knoten-ID; der Umschreiber macht daraus + `{post_id, reason: ": "}`. Der Bibliothekseintrag ist eine eigene Fundstelle (eigene Zeile in + `rowsFor()`). + +- [ ] **Step 1: Failing Test schreiben** + +`tests/Unit/Integrations/Divi/Design/PageRewriterTest.php`: + +```php +'; + + private const TEXT = ''; + + private const INSTANCE = ''; + + /** @var list Post ID and scope of every save. */ + private array $saved = []; + + /** @var list Thrown by the n-th save instead of saving (null = save). */ + private array $failures = []; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + Functions\when('wp_revisions_enabled')->justReturn(true); + $this->log = new FakeChangeLog(1); + } + + /** One section, row and column holding $modules (IDs s0.r0.c0.m0, …). */ + private static function layout(string ...$modules): string + { + return '' + . '' + . implode('', $modules) + . ''; + } + + /** @param array $fields */ + private static function page(int $id, string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Seite ' . $id, + 'post_content' => $content, + ], $fields)); + } + + /** @param list $blocked */ + private static function report( + int $count, + array $blocked = [], + int $live = 0, + bool $tooMany = false, + int $hidden = 0 + ): UsageReport { + return new UsageReport($count, [], $hidden, $live, $blocked, 0, $tooMany); + } + + /** + * The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every + * capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id). + * + * @param list $posts + * @param array $libraryReports + */ + private function rewriter(array $posts, ?UsageReport $report = null, array $libraryReports = []): PageRewriter + { + LibraryFixtures::given($posts); + $rows = array_map( + static fn (WP_Post $post): array => [ + 'id' => (int) $post->ID, + 'status' => (string) $post->post_status, + 'type' => (string) $post->post_type, + ], + $posts + ); + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('rowsFor')->andReturn($rows); + $usages->allows('reportFor')->andReturn($report ?? self::report(count($rows))); + $usages->allows('report')->andReturnUsing( + static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0) + ); + + return new PageRewriter($usages, LibraryFixtures::loader(), $this->saver(), $this->log); + } + + /** Records post ID and scope, puts the content on the post and reports revision 900 + n. */ + private function saver(): DiviSaver + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $failure = array_shift($this->failures); + if ($failure !== null) { + throw $failure; + } + $this->saved[] = [(int) $post->ID, $scope]; + $post->post_content = $content; + + return ['revision_id' => 900 + count($this->saved), 'warnings' => []]; + }); + + return $saver; + } + + /** + * A strategy like AssignRewrite: every block of $modules gets the preset stack ["p-cta"] (a block that has it + * stays), each changed block an overlap note; global instances of these modules concern the change. + * + * @param list $modules + * @return Closure(): NodeRewrite + */ + private static function assign(array $modules = ['divi/button']): Closure + { + return static fn (): NodeRewrite => new class ($modules) implements NodeRewrite { + /** @param list $modules */ + public function __construct(private array $modules) + { + } + + public function rewrite(Block $block): ?array + { + $attrs = $block->attrs(); + if (! in_array($block->name(), $this->modules, true) || self::assigned($attrs)) { + return null; + } + + return ['modulePreset' => ['p-cta']] + $attrs; + } + + public function notes(Block $block, string $nodeId): array + { + return self::assigned($block->attrs()) ? [$nodeId . ': ' . PageRewriterTest::NOTE] : []; + } + + public function concernsInstance(Block $instance): bool + { + return in_array(GlobalInstance::effectiveName($instance), $this->modules, true); + } + + /** @param array $attrs */ + private static function assigned(array $attrs): bool + { + return ($attrs['modulePreset'] ?? null) === ['p-cta']; + } + }; + } + + private static function query(): UsageQuery + { + return Mockery::mock(UsageQuery::class); + } + + /** Runs $call with error_log() going to a temp file (Log::failure() writes there). */ + private static function quietly(Closure $call): mixed + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $file); + try { + return $call(); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + } + + public function testPreviewListsPagesAndNodesWithoutWriting(): void + { + $rewriter = $this->rewriter( + [ + self::page(101, self::layout(self::BUTTON, self::TEXT, self::BUTTON)), + self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']), + ], + self::report(2, [], 1, false, 3) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + + self::assertSame(2, $preview['pages_count']); + self::assertSame(3, $preview['nodes_count']); + self::assertSame([101, 102], array_column($preview['pages'], 'post_id')); + self::assertSame('Seite 101', $preview['pages'][0]['title']); + self::assertSame('page', $preview['pages'][0]['type']); + self::assertSame('draft', $preview['pages'][0]['status']); + self::assertSame('publish', $preview['pages'][1]['status']); + self::assertSame(['s0.r0.c0.m0', 's0.r0.c0.m2'], array_column($preview['pages'][0]['nodes'], 'id')); + self::assertSame( + ['s0.r0.c0.m0: ' . self::NOTE, 's0.r0.c0.m2: ' . self::NOTE], + $preview['pages'][0]['warnings'] + ); + self::assertSame([], $preview['skipped']); + self::assertSame([], $preview['blocked_ids']); + self::assertSame(0, $preview['blocked_hidden_count']); + self::assertSame(3, $preview['hidden_count']); + self::assertSame([], $this->saved, 'the dry run writes nothing'); + self::assertSame([], $this->log->records); + } + + /** Ruling P8a like GlobalEdits: one blocked live usage stops the whole write; post_ids limit the check. */ + public function testBlockedLivePageStopsTheWholeWrite(): void + { + $rewriter = $this->rewriter( + [ + self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']), + self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']), + ], + self::report(2, [102], 2) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + self::assertSame([102], $preview['blocked_ids']); + self::assertSame(2, $preview['pages_count']); + + $error = self::assertToolError( + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY), + 'forbidden' + ); + self::assertSame([102], $error['blocked_ids']); + self::assertStringContainsString('IDs 102', $error['message']); + self::assertStringContainsString('nichts wurde gespeichert', $error['message']); + self::assertSame([], $this->saved); + self::assertSame([], $this->log->records); + + $limited = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101], self::SUMMARY); + + self::assertSame([101], array_column($limited['saved'], 'post_id')); + self::assertSame([[101, SaveScope::Page]], $this->saved); + } + + public function testTooManyLiveUsagesAreUnsupported(): void + { + $rewriter = $this->rewriter( + [self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish'])], + self::report(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true) + ); + + $error = self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported'); + self::assertStringContainsString(sprintf('mehr als %d', GlobalUsages::HARD_CAP), $error['message']); + self::assertToolError( + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY), + 'unsupported' + ); + self::assertSame([], $this->saved); + } + + /** A6: 51 pages – 50 saved, remaining 1; the next call saves the last one, remaining 0. */ + public function testWritesAtMostFiftyPagesAndTheNextCallTheRest(): void + { + $posts = array_map( + static fn (int $id): WP_Post => self::page($id, self::layout(self::BUTTON)), + range(101, 151) + ); + $rewriter = $this->rewriter($posts); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + self::assertSame(51, $preview['pages_count']); + self::assertCount(PageRewriter::PAGE_LIMIT, $preview['pages']); + + $first = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertCount(PageRewriter::PAGE_LIMIT, $first['saved']); + self::assertSame(['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], $first['saved'][0]); + self::assertSame(1, $first['remaining']); + self::assertSame('Eintrag 101: s0.r0.c0.m0: ' . self::NOTE, $first['warnings'][0]); + self::assertSame([], $first['skipped']); + self::assertSame(range(101, 150), array_column($this->saved, 0)); + self::assertCount(PageRewriter::PAGE_LIMIT, $this->log->records); + self::assertSame( + [ + 'ability' => self::ABILITY, + 'object_type' => 'page', + 'object_id' => '101', + 'summary' => 'Divi "Seite 101": ' . self::SUMMARY, + 'revision_id' => 901, + 'snapshot_id' => null, + 'result' => 'ok', + ], + $this->log->records[0] + ); + self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $posts[0]->post_content); + + $second = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([['post_id' => 151, 'revision_id' => 951, 'nodes' => 1]], $second['saved']); + self::assertSame(0, $second['remaining']); + self::assertSame([], $second['skipped']); + } + + public function testPageWithoutChangeIsNotSavedOrCounted(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::TEXT)), + self::page(102, self::layout(self::BUTTON)), + ]); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame(1, $preview['pages_count']); + self::assertSame([102], array_column($result['saved'], 'post_id')); + self::assertSame(0, $result['remaining']); + self::assertSame([], $result['skipped']); + self::assertSame(['102'], array_column($this->log->records, 'object_id')); + } + + /** R8: pages loadWritable() refuses (Divi 4, unparsable – fail-closed candidates) are skipped, not remaining. */ + public function testRefusedPagesAreSkippedWithTheLoaderMessage(): void + { + $rewriter = $this->rewriter([ + self::page( + 101, + '[et_pb_section][et_pb_text]Alt[/et_pb_text][/et_pb_section]', + ['post_status' => 'publish'] + ), + self::page(102, ''), + self::page(103, self::layout(self::BUTTON)), + ]); + + $result = self::quietly( + fn (): array => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + self::assertSame([103], array_column($result['saved'], 'post_id')); + self::assertSame(0, $result['remaining']); + self::assertSame([101, 102], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString('Divi-4-Inhalt', $result['skipped'][0]['reason']); + self::assertStringContainsString('kein gültiges Block-Markup', $result['skipped'][1]['reason']); + } + + /** Spec §6.6: a failed page keeps the pages saved before – partial_write with their revisions. */ + public function testFailedPageIsPartial(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + self::page(103, self::layout(self::BUTTON)), + ]); + $this->failures = [null, null, WriteFailed::rolledBack(DiviSaver::REASON, postId: 103)]; + + $result = self::quietly( + fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + $error = self::assertToolError($result, 'partial_write'); + self::assertSame( + [ + ['post_id' => 101, 'revision_id' => 901, 'nodes' => 1], + ['post_id' => 102, 'revision_id' => 902, 'nodes' => 1], + ], + $error['saved'] + ); + self::assertSame(103, $error['post_id']); + self::assertStringContainsString('Eintrag 103: ' . DiviSaver::REASON, $error['message']); + self::assertStringContainsString('2 Einträge davor sind gespeichert', $error['message']); + self::assertStringContainsString('Eintrag 103 blieb unverändert.', $error['message']); + $this->log->assertRecorded( + ['object_id' => '101', 'revision_id' => 901], + ['object_id' => '102', 'revision_id' => 902] + ); + } + + /** Like DiviWriter: a page the saver could not roll back is logged as partial. */ + public function testPageThatWasNotRolledBackIsLoggedAsPartial(): void + { + $this->log = FakeChangeLog::any(); + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + ]); + $this->failures = [null, WriteFailed::partial(DiviSaver::REASON, postId: 102, revisionId: 812)]; + + $result = self::quietly( + fn () => $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY) + ); + + $error = self::assertToolError($result, 'partial_write'); + self::assertSame(812, $error['revision_id']); + self::assertStringContainsString('1 Eintrag davor ist gespeichert', $error['message']); + self::assertStringContainsString( + 'Eintrag 102 ist möglicherweise teilweise geändert (Stand vorher: Revision 812).', + $error['message'] + ); + $this->log->assertRecorded(['object_id' => '101']); + $this->log->assertPartials([ + 'object_id' => '102', + 'summary' => 'Divi "Seite 102": Änderung nur teilweise zurückgenommen', + 'revision_id' => 812, + ]); + } + + /** Spec §5.5: an unreleased Divi before the first page is the release refusal (AbilityRegistrar). */ + public function testLockedDiviBeforeTheFirstPageIsThrown(): void + { + $rewriter = $this->rewriter([self::page(101, self::layout(self::BUTTON))]); + $this->failures = [new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.16.0'))]; + + $this->expectException(DiviLocked::class); + + $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + } + + /** R8: a global instance is never rewritten; it is skipped with its node ID, the rest of the page is saved. */ + public function testGlobalInstanceIsSkippedAndKeptAsItIs(): void + { + $post = self::page(101, self::layout(self::INSTANCE, self::BUTTON)); + $rewriter = $this->rewriter([$post]); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + + self::assertSame(['s0.r0.c0.m0'], array_column($preview['pages'][0]['nodes'], 'id')); + self::assertCount(1, $preview['skipped']); + self::assertSame(101, $preview['skipped'][0]['post_id']); + self::assertStringStartsWith('s0.r0.c0.g0: ', $preview['skipped'][0]['reason']); + + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([101], array_column($result['saved'], 'post_id')); + self::assertStringContainsString(self::INSTANCE, (string) $post->post_content); + self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content); + } + + /** R8: library entries are usages of their own, saved with DiviWriter's scope. */ + public function testLibraryEntriesAreSavedWithTheirScope(): void + { + $rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]); + + $result = $rewriter->apply( + self::ABILITY, + self::query(), + self::assign(['divi/blurb', 'divi/text']), + null, + self::SUMMARY + ); + + self::assertSame([1057, 1059], array_column($result['saved'], 'post_id')); + self::assertSame([[1057, SaveScope::LibraryItem], [1059, SaveScope::GlobalItem]], $this->saved); + $this->log->assertRecorded( + ['object_type' => 'et_pb_layout', 'object_id' => '1057'], + ['object_type' => 'et_pb_layout', 'object_id' => '1059'] + ); + } + + /** Audit L2: a normal entry that pages show as instance is saved site-wide as well. */ + public function testReferencedLibraryEntryIsSavedSiteWide(): void + { + $rewriter = $this->rewriter([LibraryFixtures::item(1057)], null, [1057 => self::report(1)]); + + $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/blurb']), null, self::SUMMARY); + + self::assertSame([[1057, SaveScope::GlobalItem]], $this->saved); + } + + /** P8a for the pages that show an entry: blocked → the entry is skipped, the others are saved. */ + public function testLibraryEntryShownOnBlockedPagesIsSkipped(): void + { + $rewriter = $this->rewriter( + [LibraryFixtures::item(1059), self::page(101, self::layout(self::TEXT))], + null, + [1059 => self::report(1, [7], 1)] + ); + + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(['divi/text']), null, self::SUMMARY); + + self::assertSame([[101, SaveScope::Page]], $this->saved); + self::assertSame([1059], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString('Bibliothekseintrag 1059', $result['skipped'][0]['reason']); + self::assertStringContainsString('IDs 7', $result['skipped'][0]['reason']); + } + + /** No backup copies in a bulk write: a site-wide entry without revisions is skipped. */ + public function testSiteWideEntryWithoutRevisionsIsSkipped(): void + { + Functions\when('wp_revisions_enabled')->justReturn(false); + $rewriter = $this->rewriter([LibraryFixtures::item(1057), LibraryFixtures::item(1059)]); + + $result = $rewriter->apply( + self::ABILITY, + self::query(), + self::assign(['divi/blurb', 'divi/text']), + null, + self::SUMMARY + ); + + self::assertSame([[1057, SaveScope::LibraryItem]], $this->saved); + self::assertSame([1059], array_column($result['skipped'], 'post_id')); + self::assertStringContainsString( + 'Revisionen sind für Bibliothekseintrag 1059 abgeschaltet', + $result['skipped'][0]['reason'] + ); + } + + public function testPostIdsLimitTheCandidatesAndNameUnchangedIds(): void + { + $rewriter = $this->rewriter([ + self::page(101, self::layout(self::BUTTON)), + self::page(102, self::layout(self::BUTTON)), + ]); + + $preview = $rewriter->preview(self::query(), self::assign(), [102, 999]); + + self::assertSame([102], array_column($preview['pages'], 'post_id')); + self::assertSame([['post_id' => 999, 'reason' => PageRewriter::NO_CHANGE]], $preview['skipped']); + self::assertSame(0, $preview['hidden_count']); + } + + /** Audit L4: candidates the caller may not read are neither loaded nor named, only counted. */ + public function testUnreadableCandidatesAreOnlyCounted(): void + { + $rewriter = $this->rewriter( + [self::page(101, self::layout(self::BUTTON)), self::page(102, self::layout(self::BUTTON))], + self::report(2, [], 0, false, 1) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'read_post' && ($args[0] ?? null) === 102) + ); + + $preview = $rewriter->preview(self::query(), self::assign(), null); + $result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY); + + self::assertSame([101], array_column($preview['pages'], 'post_id')); + self::assertSame([], $preview['skipped']); + self::assertSame(1, $preview['hidden_count']); + self::assertSame([[101, SaveScope::Page]], $this->saved); + self::assertSame([], $result['skipped']); + } +} +``` + +- [ ] **Step 2: Test laufen lassen – er schlägt fehl** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PageRewriterTest.php` +Expected: FAIL – jeder Test mit `Error: Class "LIT\MCP\Integrations\Divi\Design\PageRewriter" not found`. + +- [ ] **Step 3: `PageRewriter` implementieren** + +`src/Integrations/Divi/Design/PageRewriter.php`: + +```php +|null $postIds Only these posts; null = every usage. + * @return array{pages: list, warnings: list}>, + * pages_count: int, nodes_count: int, skipped: list, + * blocked_ids: list, blocked_hidden_count: int, hidden_count: int}|WP_Error + */ + public function preview(UsageQuery $query, Closure $rewrite, ?array $postIds): array|WP_Error + { + $ids = self::ids($postIds); + $report = $this->usages->reportFor($query); + if ($ids === null && $report->tooMany) { + return self::tooMany($report); + } + + $skipped = []; + $pages = []; + $pagesCount = 0; + $nodesCount = 0; + foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) { + ++$pagesCount; + $nodesCount += count($change['result']['changed']); + if (count($pages) < self::PAGE_LIMIT) { + $pages[] = self::listing($change); + } + } + + $blocked = self::blocked($report, $ids); + + return [ + 'pages' => $pages, + 'pages_count' => $pagesCount, + 'nodes_count' => $nodesCount, + 'skipped' => $skipped, + 'blocked_ids' => $blocked['ids'], + 'blocked_hidden_count' => $blocked['hidden'], + 'hidden_count' => $ids === null ? $report->hidden : 0, + ]; + } + + /** + * Writes at most PAGE_LIMIT posts – call it only after the caller's confirm. Each saved post gets its revision + * (DiviSaver) and a change log entry "Divi "": $summary". + * + * @param Closure(): NodeRewrite $rewrite A fresh strategy per post. + * @param list<int>|null $postIds Only these posts; null = every usage. + * @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int, + * warnings: list<string>, skipped: list<array{post_id: int, reason: string}>}|WP_Error + * unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved) + * @throws WriteFailed When saving the first post fails. + * @throws DiviLocked When Divi is not released – before the first post is written. + */ + public function apply( + string $ability, + UsageQuery $query, + Closure $rewrite, + ?array $postIds, + string $summary + ): array|WP_Error { + $ids = self::ids($postIds); + $refusal = $this->refusal($this->usages->reportFor($query), $ids); + if ($refusal !== null) { + return $refusal; + } + + $skipped = []; + $saved = []; + $warnings = []; + $remaining = 0; + foreach ($this->changes($query, $rewrite, $ids, $skipped) as $change) { + if (count($saved) >= self::PAGE_LIMIT) { + ++$remaining; + continue; + } + + try { + $saved[] = $this->save($ability, $change, $summary, $warnings); + } catch (WriteFailed | DiviLocked $e) { + return $this->failed($ability, $change['page']->post, $e, $saved); + } + } + + return ['saved' => $saved, 'remaining' => $remaining, 'warnings' => $warnings, 'skipped' => $skipped]; + } + + /** + * Every candidate post that the rewrite changes, one after the other (ID order). Candidates are read in chunks + * with a primed post cache that is dropped afterwards (audit L1). Posts left alone with a reason are added to + * $skipped; post_ids that changed nothing and have no other reason get NO_CHANGE at the end. + * + * @param Closure(): NodeRewrite $rewrite + * @param list<int>|null $ids + * @param list<array{post_id: int, reason: string}> $skipped + * @return Generator<int, array{page: DiviPage, result: array{changed: list<array{id: string, module: string, + * label: string}>, warnings: list<string>, skipped: list<array{id: string, reason: string}>}, + * content: string, scope: SaveScope}> + */ + private function changes(UsageQuery $query, Closure $rewrite, ?array $ids, array &$skipped): Generator + { + $rows = $this->usages->rowsFor($query); + if ($ids !== null) { + $rows = array_filter($rows, static fn (array $row): bool => in_array($row['id'], $ids, true)); + } + + $seen = []; + foreach (array_chunk(array_column($rows, 'id'), GlobalUsages::CONTENT_CHUNK) as $chunk) { + $fresh = _get_non_cached_ids($chunk, 'posts'); + _prime_post_caches($chunk, false, false); + try { + foreach ($chunk as $id) { + $before = count($skipped); + $change = $this->change((int) $id, $rewrite, $skipped); + if ($change !== null || count($skipped) > $before) { + $seen[] = (int) $id; + } + if ($change !== null) { + yield $change; + } + } + } finally { + foreach ($fresh as $freshId) { + wp_cache_delete((int) $freshId, 'posts'); + } + } + } + + foreach (array_diff($ids ?? [], $seen) as $id) { + $skipped[] = ['post_id' => $id, 'reason' => self::NO_CHANGE]; + } + } + + /** + * Post $id loaded fresh and rewritten in memory; null when nothing changes or the post is left alone (then + * with a reason in $skipped, unless the caller may not see it). + * + * @param Closure(): NodeRewrite $rewrite + * @param list<array{post_id: int, reason: string}> $skipped + * @return array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope}|null + */ + private function change(int $id, Closure $rewrite, array &$skipped): ?array + { + if (! current_user_can('read_post', $id)) { + return null; + } + + $page = $this->loader->loadWritable($id); + if ($page instanceof WP_Error) { + // A hidden library entry or Theme Builder layout reads like a missing ID (P12): never named. + if (Errors::codeOf($page) !== 'not_found') { + $skipped[] = ['post_id' => $id, 'reason' => Errors::clientMessage($page)]; + } + + return null; + } + + $result = (new TreeRewrite($rewrite()))->apply($page->index); + foreach ($result['skipped'] as $node) { + $skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']]; + } + + $content = Serializer::document($page->document); + if ($result['changed'] === [] || $content === (string) $page->post->post_content) { + return null; + } + + $scope = $this->scope($page); + if (is_string($scope)) { + $skipped[] = ['post_id' => $id, 'reason' => $scope]; + + return null; + } + + return ['page' => $page, 'result' => $result, 'content' => $content, 'scope' => $scope]; + } + + /** + * The save scope of $page as DiviWriter chooses it, or why the rewriter leaves $page alone: a library entry + * shown on live posts the caller may not edit (P8a), a site-wide entry or a Theme Builder layout without + * revisions (no restore point). + */ + private function scope(DiviPage $page): SaveScope|string + { + $post = $page->post; + $id = (int) $post->ID; + if ($page->isThemeBuilderLayout()) { + return self::withoutRevisions($post, sprintf('Theme-Builder-Layout %d', $id)) + ?? SaveScope::ThemeBuilderLayout; + } + + if (! $page->isLibraryItem()) { + return SaveScope::Page; + } + + $report = $this->usages->report($id); + $global = $this->loader->library()?->isGlobal($post) ?? false; + if (! $global && ! $report->exists()) { + return SaveScope::LibraryItem; + } + + if ($report->blocks()) { + return self::blockedEntry($id, $report); + } + + return self::withoutRevisions($post, sprintf('Bibliothekseintrag %d', $id)) ?? SaveScope::GlobalItem; + } + + /** + * Saves one changed post, then lit_mcp_after_save and the change log (failures there are warnings – the post + * is saved). Warnings join $warnings with the post ID in front. + * + * @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope} $change + * @param list<string> $warnings + * @return array{post_id: int, revision_id: ?int, nodes: int} + * @throws WriteFailed + * @throws DiviLocked + */ + private function save(string $ability, array $change, string $summary, array &$warnings): array + { + $post = $change['page']->post; + $id = (int) $post->ID; + $saved = $this->saver->save($post, $change['content'], $change['scope']); + + $notes = array_merge($change['result']['warnings'], $saved['warnings']); + FollowUp::run( + static fn () => do_action('lit_mcp_after_save', $id, $ability, ['post_id' => $id]), + $ability . ' lit_mcp_after_save', + 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', + $notes + ); + FollowUp::run( + fn () => $this->log->record( + $ability, + (string) $post->post_type, + (string) $id, + sprintf('Divi "%s": %s', $post->post_title, $summary), + $saved['revision_id'] + ), + $ability . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', + $notes + ); + foreach ($notes as $note) { + $warnings[] = sprintf('Eintrag %d: %s', $id, $note); + } + + return [ + 'post_id' => $id, + 'revision_id' => $saved['revision_id'], + 'nodes' => count($change['result']['changed']), + ]; + } + + /** + * Saving $post failed after the posts in $saved: they stay saved (each with its revision) – partial_write + * with data.saved. A post the saver could not roll back is logged as partial (like DiviWriter). + * + * @param list<array{post_id: int, revision_id: ?int, nodes: int}> $saved + * @throws WriteFailed $e itself when nothing was saved before (the registrar answers as for every Divi write). + * @throws DiviLocked $e itself when nothing was saved before. + */ + private function failed(string $ability, WP_Post $post, WriteFailed|DiviLocked $e, array $saved): WP_Error + { + $id = (int) $post->ID; + $partial = $e instanceof WriteFailed && ! $e->rolledBack; + $revisionId = $e instanceof WriteFailed ? $e->revisionId : null; + if ($partial) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $id, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $revisionId + ); + } + + if ($saved === []) { + throw $e; + } + + Log::failure(sprintf('%s: post %d after %d saved posts', $ability, $id, count($saved)), $e); + $reason = $e instanceof WriteFailed ? $e->reason : Errors::clientMessage($e->toError()); + $count = count($saved); + $failedPost = $partial + ? sprintf('Eintrag %d ist möglicherweise teilweise geändert', $id) + . ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId)) + : sprintf('Eintrag %d blieb unverändert.', $id); + + return Errors::partialWrite( + sprintf('Eintrag %d: %s', $id, $reason), + $id, + $revisionId, + sprintf( + '%d %s davor %s gespeichert (siehe saved, je mit Revision); %s', + $count, + $count === 1 ? 'Eintrag' : 'Einträge', + $count === 1 ? 'ist' : 'sind', + $failedPost + ), + sprintf( + 'Den Aufruf nicht einfach wiederholen. Zuerst Eintrag %d mit lit-divi-outline prüfen und dem Nutzer ' + . 'Bescheid geben; danach denselben Aufruf mit confirm: true erneut senden – bereits geänderte ' + . 'Module trifft er nicht mehr.', + $id + ), + ['saved' => $saved] + ); + } + + /** Ruling P8a like GlobalEdits::pagesRefusal(): above HARD_CAP or with a blocked live usage nothing is written. */ + private function refusal(UsageReport $report, ?array $ids): ?WP_Error + { + if ($ids === null && $report->tooMany) { + return self::tooMany($report); + } + + $blocked = self::blocked($report, $ids); + if ($blocked['ids'] === [] && $blocked['hidden'] === 0) { + return null; + } + + return Errors::make( + 'forbidden', + sprintf( + 'Die Änderung betrifft veröffentlichte, private oder geplante Einträge, die dieses Konto nicht ' + . 'bearbeiten darf (%s); nichts wurde gespeichert.', + self::blockedList($blocked['ids'], $blocked['hidden']) + ), + 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' + . 'oder Administrator), oder sie mit post_ids auf die eigenen Einträge beschränken.', + 403, + ['blocked_ids' => $blocked['ids']] + + ($blocked['hidden'] > 0 ? ['blocked_hidden_count' => $blocked['hidden']] : []) + ); + } + + private static function tooMany(UsageReport $report): WP_Error + { + return Errors::unsupported( + sprintf( + 'Die Änderung betrifft %d veröffentlichte, private oder geplante Einträge – mehr als %d; so große ' + . 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.', + $report->live, + GlobalUsages::HARD_CAP + ), + 'Die Änderung mit post_ids auf einzelne Einträge beschränken oder die Module im Divi-Builder ändern.' + ); + } + + /** + * The blocking posts that count: all of the report, or with post_ids only those among them (unreadable ones + * cannot be matched to IDs; loadWritable() refuses them one by one). + * + * @param list<int>|null $ids + * @return array{ids: list<int>, hidden: int} + */ + private static function blocked(UsageReport $report, ?array $ids): array + { + if ($ids === null) { + return ['ids' => $report->blocked, 'hidden' => $report->blockedHidden]; + } + + return ['ids' => array_values(array_intersect($report->blocked, $ids)), 'hidden' => 0]; + } + + /** Why library entry $id stays unchanged: pages that show it block the edit (P8a, audit L1). */ + private static function blockedEntry(int $id, UsageReport $report): string + { + if ($report->tooMany) { + return sprintf( + 'Bibliothekseintrag %d erscheint auf mehr als %d veröffentlichten, privaten oder geplanten Einträgen; ' + . 'er bleibt unverändert – ihn im Divi-Builder bearbeiten.', + $id, + GlobalUsages::HARD_CAP + ); + } + + return sprintf( + 'Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto ' + . 'nicht bearbeiten darf (%s); er bleibt unverändert.', + $id, + self::blockedList($report->blocked, $report->blockedHidden) + ); + } + + /** Why $post stays unchanged when revisions are off for it (no restore point, no backup copy in bulk). */ + private static function withoutRevisions(WP_Post $post, string $label): ?string + { + if (wp_revisions_enabled($post)) { + return null; + } + + return sprintf( + 'Revisionen sind für %s abgeschaltet (kein Wiederherstellungspunkt); unverändert gelassen – Revisionen ' + . 'aktivieren (WP_POST_REVISIONS) oder im Divi-Builder bearbeiten.', + $label + ); + } + + /** + * Readable blocking posts by ID (at most LISTED_IDS), unreadable ones as a count – as GlobalEdits names them. + * + * @param list<int> $ids + */ + private static function blockedList(array $ids, int $hidden): string + { + $parts = []; + if ($ids !== []) { + $more = count($ids) - self::LISTED_IDS; + $parts[] = 'IDs ' . implode(', ', array_slice($ids, 0, self::LISTED_IDS)) + . ($more > 0 ? sprintf(' und %d weitere', $more) : ''); + } + if ($hidden > 0) { + $parts[] = sprintf( + '%d %s dieses Konto nicht lesen darf', + $hidden, + $hidden === 1 ? 'Eintrag, den' : 'Einträge, die' + ); + } + + return implode('; ', $parts); + } + + /** + * @param array{page: DiviPage, result: array{changed: list<array{id: string, module: string, label: string}>, + * warnings: list<string>, skipped: list<array{id: string, reason: string}>}, content: string, + * scope: SaveScope} $change + * @return array{post_id: int, title: string, type: string, status: string, + * nodes: list<array{id: string, module: string, label: string}>, warnings: list<string>} + */ + private static function listing(array $change): array + { + $post = $change['page']->post; + + return [ + 'post_id' => (int) $post->ID, + 'title' => (string) $post->post_title, + 'type' => (string) $post->post_type, + 'status' => (string) $post->post_status, + 'nodes' => $change['result']['changed'], + 'warnings' => $change['result']['warnings'], + ]; + } + + /** + * @param array<mixed>|null $postIds + * @return list<int>|null + */ + private static function ids(?array $postIds): ?array + { + return $postIds === null ? null : array_values(array_unique(array_map('intval', $postIds))); + } +} +``` + +- [ ] **Step 4: Factory in `DiviIntegration`** + +In `src/Integrations/Divi/DiviIntegration.php` den Import direkt nach `use …\Design\DiviPresetsSetAbility;` ergänzen: + +```php +use LIT\MCP\Integrations\Divi\Design\PageRewriter; +``` + +und nach `designChange()` einfügen: + +```php + /** + * The page rewriter of lit/divi-presets-assign and lit/divi-design-detach (Etappe 4b, spec §6.6): usages with + * this integration's Theme Builder access, posts through $loader (shared with the other Divi tools). + */ + private function pageRewriter(DiviPageLoader $loader): PageRewriter + { + return new PageRewriter($this->globalUsages(), $loader, new DiviSaver(), $this->log); + } +``` + +Die Methode bleibt bis Task 9 unbenutzt (Task 9 ruft `$this->pageRewriter($loader)` in `abilities()` auf und deckt +die Verdrahtung über `DiviIntegrationTest::EXPECTED` ab). + +- [ ] **Step 5: Test laufen lassen – er besteht** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PageRewriterTest.php` +Expected: PASS (16 Tests). + +- [ ] **Step 6: Gesamtlauf und Lint** + +Run: `composer test && composer lint` +Expected: alle Tests grün (2454 + Tests der Tasks 1–7 + 16), phpcs ohne Meldung. + +- [ ] **Step 7: Commit** + +```bash +git add src/Integrations/Divi/Design/PageRewriter.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/PageRewriterTest.php +git commit -m "feat: Seiten-Umschreiber für site-weite Preset-Änderungen (Probelauf, höchstens 50 Seiten, Revision und Protokoll je Seite)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" +``` + +#### Offene Punkte (Task 8) + +1. **Doppelte Kandidatensuche:** `reportFor()` ruft intern `rowsFor()`; der Umschreiber ruft `rowsFor()` noch einmal + (zwei Kandidaten-Abfragen und zwei Inhaltsdurchläufe je Aufruf). Bewusst hingenommen, um `GlobalUsages` nicht zu + ändern; später ggf. `GlobalUsages::reportOfRows()` öffentlich machen und die Zeilen einmal lesen. +2. **Laufzeit bei großen Sites:** Bis `PARSE_CAP` (5000) Kandidaten werden vollständig geladen und umgerechnet – im + Probelauf und in jedem `apply` (für `remaining`). Speicher ist begrenzt (Cache-Abwurf je Block), die Laufzeit nicht; + im Smoke (Task 11) mit vielen Seiten messen. Falls zu langsam: `remaining` ab einer Grenze als „mindestens N“ melden + (Spec-Änderung nötig). +3. **Bezeichner aus `TreeRewrite` (Task 7):** Der Umschreiber übernimmt `changed[*].module/label` und + `skipped[*].reason` unverändert und setzt nur `"<Knoten-ID>: "` vor den Grund. Der Test prüft daher nur Knoten-IDs + und das Präfix; formuliert Task 7 seinen Grund bereits mit Knoten-ID, das Präfix in `change()` entfernen. +4. **`blockedList()` dupliziert `GlobalEdits::blockedList()/listed()`** (dort privat). Eine gemeinsame Methode z. B. + `UsageReport::blockedText()` wäre sauberer; nicht in diesem Task, um `GlobalEdits`-Texte und -Tests nicht anzufassen. +5. **Theme-Builder-Layouts ohne Revisionen** werden immer übersprungen, auch wenn keine Live-Vorlage sie zeigt + (`ThemeBuilderEdits` würde dann ohne Sicherung speichern). Strenger als `DiviWriter`, vermeidet aber Vorlagen-Lesungen + je Layout; bei Bedarf über `ThemeBuilderEdits::showing()` verfeinern (bräuchte `ThemeBuilderEdits` im Konstruktor). +6. **Mit `post_ids`** blockieren nur die genannten Seiten, und `tooMany` lehnt nicht ab (Entscheidung 1). Tasks 9/10 + müssen das in Tool-Beschreibung und Guide (Task 11) so nennen. +7. **`lit_mcp_after_save`** feuert je gespeicherter Seite mit der Eingabe `['post_id' => <ID>]` statt der + Tool-Eingabe (die gehört zu allen Seiten). Erweiterungen, die die Tool-Eingabe auswerten, sehen bei + Massenänderungen nur die ID. +8. **Vorab geprüft:** Test und Implementierung dieses Tasks liefen in einer Kopie des Repos (Stand 3e77450) mit einer + Minimal-Fassung von `NodeRewrite`/`TreeRewrite` nach den Gerüst-Signaturen grün (16 Tests; Gesamtlauf 2470 Tests; + phpcs sauber für `PageRewriter.php` und `DiviIntegration.php`). Weicht die echte `TreeRewrite` (Task 7) ab – etwa + Notizen auch für unveränderte Blöcke oder andere Knoten-IDs für Instanzen –, schlagen + `testPreviewListsPagesAndNodesWithoutWriting` bzw. `testGlobalInstanceIsSkippedAndKeptAsItIs` an. + +--- + +## Signatur-Änderungen und -Ergänzungen (Tasks 9–10) + +1. **`PageRewriter` ist nicht `final`** (Abweichung vom Gerüst, das `final class PageRewriter` nennt). Grund: Die + Ability-Tests von Task 9 und 10 brauchen einen Mockery-Mock; `DiviPageLoader` ist `final` und lässt sich nicht + mocken, ein echter `PageRewriter` wäre in Ability-Tests nicht baubar. Muster wie `GlobalUsages`, `DiviSaver`, + `DiviComposition` („Not final so abilities can be tested with a mock“). Methoden und Konstanten bleiben exakt wie im + Gerüst. Task 9 Step 1 entfernt `final`, falls Task 8 es gesetzt hat. + ```php + /** … Not final so abilities can be tested with a mock (like GlobalUsages). */ + class PageRewriter + ``` +2. **Neu (rein, Task 9):** `Design/PostIdsInput.php` – gemeinsame Prüfung des Filters `post_ids` beider Tools. + ```php + final class PostIdsInput + { + public const MAX = PageRewriter::PAGE_LIMIT; + /** @return array<string, mixed> JSON-Schema der Eingabe post_ids */ + public static function schema(): array; + /** @return list<int>|null null ohne Angabe; doppelte IDs entfernt + * @throws \InvalidArgumentException „Liste von 1 bis 50 Beitrags-IDs (positive ganze Zahlen) erwartet“ */ + public static function parse(mixed $value): ?array; + } + ``` +3. **Neu (rein, Task 9):** `Design/PageRewriteReport.php` – Meldungen und Ausgabeschema rund um `PageRewriter` + (von Task 9 und 10 gemeinsam genutzt, damit Texte und Schema nicht doppelt gepflegt werden). + ```php + final class PageRewriteReport + { + /** preview(): nichts zu schreiben, nichts blockiert, nichts verborgen */ + public static function isEmpty(array $preview): bool; + public static function dryRunMessage(array $preview, string $change): string; + public static function savedSummary(array $result, string $change): string; + /** ToolResult mit saved: [], remaining: 0, skipped aus $preview */ + public static function nothingToDo(string $summary, array $preview, array $warnings = []): array; + /** ToolResult::schema mit saved, remaining, skipped (Pflicht) und $extra */ + public static function outputSchema(array $extra = []): array; + } + ``` +4. **Annahme an Task 4 (Verdrahtung):** `DiviComposition::presets(): PresetAssignment` liefert die in + Task 4 verdrahtete `PresetAssignment` (Book aus `DesignSystemWriter::presets()`, `groupsOf`, `validId` über den + `ValueCleaner`). Task 9 ruft sie über eine private Fabrik `DiviIntegration::presetAssignment()` auf. Heißt die + Fabrik aus Task 4 anders oder liegt sie schon in `DiviIntegration`, wird nur diese eine Methode angepasst bzw. + die vorhandene genutzt. +5. **Ability-Konstruktoren:** + ```php + /** @param Closure(): PresetAssignment $assignment frische Instanz (eigener Preset-Bestand) je Aufruf */ + public function __construct(DesignSystemWriter $writer, PageRewriter $pages, Closure $assignment); // Assign + /** @param (Closure(): bool)|null $unfiltered */ + public function __construct( + DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages, PageRewriter $pages, + ?Closure $unfiltered = null + ); // Detach + ``` + +--- + +### Task 9: `lit/divi-presets-assign` – Presets site-weit zuweisen + +**Files:** +- Create: `src/Integrations/Divi/Design/PostIdsInput.php` +- Create: `src/Integrations/Divi/Design/PageRewriteReport.php` +- Create: `src/Integrations/Divi/Design/DiviPresetsAssignAbility.php` +- Modify: `src/Integrations/Divi/Design/PageRewriter.php` (nur `final` entfernen, Docblock-Satz; falls nötig) +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php` + +**Interfaces:** +- Consumes: `PresetAssignment::apply(string $module, array $attrs, array $spec, string $path): array{attrs, errors}` + (Task 3), `AssignRewrite::__construct(string $module, PresetAssignment $assignment, array $spec, + bool $onlyWithoutPreset = false, ?string $onlyWithPresetId = null, bool $clearOverrides = false)` (Task 7), + `NodeRewrite` (Task 7), `ModuleUsageQuery::__construct(string $module)` (Task 6), + `PageRewriter::preview(UsageQuery, Closure, ?array): array|WP_Error`, + `PageRewriter::apply(string, UsageQuery, Closure, ?array, string): array|WP_Error`, `PageRewriter::PAGE_LIMIT` + (Task 8), `DiviIntegration::pageRewriter(DiviPageLoader $loader): PageRewriter` (Task 8), `DiviComposition::presets()` (Task 4, + siehe Signatur-Annahme 4), `DesignRights::presets()`, `PresetScope`, `PresetBook::find()`, + `DesignChange::inputProperties()`, `ToolResult`, `Errors`. +- Produces: `PostIdsInput`, `PageRewriteReport` (Signaturen oben), + `final class DiviPresetsAssignAbility implements Ability { public const MAX_STACK = 10; public function + __construct(DesignSystemWriter $writer, PageRewriter $pages, Closure $assignment) }`. + +Eingabe (Spec §6.7): `module` (Pflicht, `button` oder `divi/button`), `preset` (String oder Liste 1–10, R2), +`group_presets` (Objekt, R3), mindestens eins von beiden; Filter `post_ids` (1–50 positive IDs), +`only_without_preset` (bool) **oder** `only_with_preset` (Name/ID eines Modul-Presets dieses Moduls), +`clear_overrides` (bool), `confirm`. + +Entscheidungen in diesem Task: +- `preset: null` und `null` als Wert in `group_presets` sind hier Fehler (`validation_failed`, Feld `preset` bzw. + `group_presets.<schlüssel>`): „null entfernt site-weit nichts; Preset-Verweise entfernt lit/divi-design-detach“. + Eine Massen-Entfernung über assign wäre ein zweiter, ungeprüfter Weg neben detach. +- Die Prüfung läuft einmal modulweit über `PresetAssignment::apply($module, [], $spec, 'input')`; der Präfix + `input.` wird aus den Fehlerpfaden entfernt, damit `fields` die Eingabeschlüssel nennt (`preset`, + `preset.1`, `group_presets.font-body`). +- **Leerer Probelauf** (`pages_count` 0, keine `blocked_ids`, `hidden_count` 0) ist ein normales Ergebnis + („keine Seite betroffen, nichts zu tun“, `saved: []`, `remaining: 0`) statt `confirm_required`: Es gibt nichts zu + bestätigen, und ein wiederholter Aufruf nach dem letzten Teil endet ohne Rückfrage beim Nutzer. Mit `confirm` + ruft das Tool immer `PageRewriter::apply()` auf (das bei nichts zu tun `saved: []` liefert). +- Der Probelauf ist `Errors::confirmRequired()` mit der vollständigen `preview()`-Antwort als Details (pages, + pages_count, nodes_count, skipped, blocked_ids, hidden_count). Blockierte Seiten stehen zusätzlich in der Meldung + („mit confirm würde nichts gespeichert“) – abgelehnt wird erst beim Schreiben durch den `PageRewriter`. +- `Annotations::write(true)`: Wiederholen mit gleichen Angaben ändert nichts mehr (Filter treffen geänderte Module + nicht mehr). + +- [ ] **Step 1: Prüfen, dass `PageRewriter` mockbar ist** + +Run: `grep -n '^class PageRewriter' src/Integrations/Divi/Design/PageRewriter.php` +Expected: eine Treffer-Zeile (Task 8 legt die Klasse ohne `final` an). Steht dort `final class`, das `final` +entfernen und an den Klassen-Docblock `Not final so abilities can be tested with a mock (like GlobalUsages).` +anhängen. + +- [ ] **Step 2: Failing tests für `PostIdsInput` und `PageRewriteReport` schreiben** + +`tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; + +use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Design\PageRewriter; +use LIT\MCP\Integrations\Divi\Design\PostIdsInput; +use PHPUnit\Framework\TestCase; + +final class PostIdsInputTest extends TestCase +{ + public function testParsesAListOfPositiveIdsWithoutDuplicates(): void + { + self::assertNull(PostIdsInput::parse(null)); + self::assertSame([12, 13], PostIdsInput::parse([12, 13, 12])); + self::assertSame(PageRewriter::PAGE_LIMIT, PostIdsInput::MAX); + } + + /** @dataProvider invalidInput */ + public function testRefusesEverythingElse(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Liste von 1 bis 50 Beitrags-IDs (positive ganze Zahlen) erwartet'); + + PostIdsInput::parse($value); + } + + /** @return array<string, array{mixed}> */ + public static function invalidInput(): array + { + return [ + 'leer' => [[]], + 'null als ID' => [[0]], + 'negativ' => [[-3]], + 'Text' => [['12']], + 'Objekt' => [['a' => 1]], + 'zu viele' => [range(1, 51)], + 'keine Liste' => [12], + ]; + } + + public function testSchemaMatchesTheParser(): void + { + $schema = PostIdsInput::schema(); + + self::assertSame('array', $schema['type']); + self::assertSame(1, $schema['minItems']); + self::assertSame(50, $schema['maxItems']); + self::assertSame(['type' => 'integer', 'minimum' => 1], $schema['items']); + } +} +``` + +`tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; + +use LIT\MCP\Integrations\Divi\Design\PageRewriteReport; +use PHPUnit\Framework\TestCase; + +final class PageRewriteReportTest extends TestCase +{ + /** + * A PageRewriter::preview() result: $pages listed pages (IDs from 12) with one module each. + * + * @param list<int> $blocked + * @return array<string, mixed> + */ + public static function preview(int $pages, array $blocked = []): array + { + $listed = []; + for ($i = 0; $i < $pages; ++$i) { + $listed[] = [ + 'post_id' => 12 + $i, + 'title' => 'Seite ' . ($i + 1), + 'type' => 'page', + 'status' => 'publish', + 'nodes' => [['id' => 's0.r0.c0.m0', 'module' => 'divi/button', 'label' => 'Button']], + 'warnings' => [], + ]; + } + + return [ + 'pages' => $listed, + 'pages_count' => $pages, + 'nodes_count' => $pages, + 'skipped' => [], + 'blocked_ids' => $blocked, + 'hidden_count' => 0, + ]; + } + + /** + * A PageRewriter::apply() result: $saved pages (IDs from 12) with two modules each. + * + * @param list<array{post_id: int, reason: string}> $skipped + * @return array<string, mixed> + */ + public static function applied(int $saved, int $remaining, array $skipped = []): array + { + $rows = []; + for ($i = 0; $i < $saved; ++$i) { + $rows[] = ['post_id' => 12 + $i, 'revision_id' => 900 + $i, 'nodes' => 2]; + } + + return ['saved' => $rows, 'remaining' => $remaining, 'warnings' => [], 'skipped' => $skipped]; + } + + public function testEmptyMeansNothingToWriteNothingBlockedNothingHidden(): void + { + self::assertTrue(PageRewriteReport::isEmpty(self::preview(0))); + self::assertFalse(PageRewriteReport::isEmpty(self::preview(1))); + self::assertFalse(PageRewriteReport::isEmpty(self::preview(0, [7]))); + self::assertFalse(PageRewriteReport::isEmpty(['hidden_count' => 1] + self::preview(0))); + } + + public function testDryRunMessageNamesCountsTheLimitAndBlockedPages(): void + { + $message = PageRewriteReport::dryRunMessage(self::preview(2), 'Zuweisung an divi/button'); + + self::assertStringStartsWith('Zuweisung an divi/button – betroffen (Seiten: 2, Module: 2).', $message); + self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $message); + self::assertStringContainsString('confirm: true', $message); + self::assertStringContainsString('höchstens 50 Seiten', $message); + self::assertStringNotContainsString('blocked_ids', $message); + self::assertStringContainsString('blocked_ids', PageRewriteReport::dryRunMessage(self::preview(1, [7]), 'X')); + } + + public function testSavedSummaryAsksForAnotherCallWhilePagesRemain(): void + { + self::assertSame( + 'X – gespeichert (Seiten: 2, Module: 4). Noch 3 Seiten offen: mit denselben Angaben erneut mit ' + . 'confirm: true aufrufen.', + PageRewriteReport::savedSummary(self::applied(2, 3), 'X') + ); + self::assertSame( + 'X – gespeichert (Seiten: 1, Module: 2). Alle Fundstellen sind erledigt.', + PageRewriteReport::savedSummary(self::applied(1, 0), 'X') + ); + self::assertSame( + 'X – gespeichert (Seiten: 0, Module: 0). Übersprungen: 1 (siehe skipped).', + PageRewriteReport::savedSummary(self::applied(0, 0, [['post_id' => 5, 'reason' => 'global']]), 'X') + ); + } + + public function testNothingToDoIsAToolResultWithTheSkippedPages(): void + { + $preview = ['skipped' => [['post_id' => 5, 'reason' => 'global']]] + self::preview(0); + + $result = PageRewriteReport::nothingToDo('Nichts zu tun.', $preview, ['Hinweis']); + + self::assertTrue($result['ok']); + self::assertSame('Nichts zu tun.', $result['summary']); + self::assertSame(['Hinweis'], $result['warnings']); + self::assertSame([[], 0], [$result['saved'], $result['remaining']]); + self::assertSame(5, $result['skipped'][0]['post_id']); + } + + public function testOutputSchemaRequiresTheRewriteKeys(): void + { + $schema = PageRewriteReport::outputSchema(['diff' => ['type' => 'array']]); + + self::assertSame(['saved', 'remaining', 'skipped'], array_slice($schema['required'], -3)); + self::assertSame('integer', $schema['properties']['remaining']['type']); + self::assertSame(['type' => 'array'], $schema['properties']['diff']); + } +} +``` + +- [ ] **Step 3: Failing tests für `lit/divi-presets-assign` schreiben** + +`tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; + +use Brain\Monkey\Functions; +use Closure; +use LIT\MCP\Integrations\Divi\Design\AssignRewrite; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility; +use LIT\MCP\Integrations\Divi\Design\ModuleUsageQuery; +use LIT\MCP\Integrations\Divi\Design\PageRewriter; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; +use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use Mockery\MockInterface; +use ReflectionProperty; + +final class DiviPresetsAssignAbilityTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(PageRewriter $pages): DiviPresetsAssignAbility + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + + return new DiviPresetsAssignAbility( + $writer, + $pages, + static fn (): PresetAssignment => new PresetAssignment( + static fn (): PresetBook => PresetBook::fromDivi(PresetBookTest::stored()), + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [], + static fn (string $id): bool => preg_match('/^[0-9a-z]{10}$/', $id) === 1 + ) + ); + } + + /** A PageRewriter that must not be asked at all. */ + private static function untouched(): PageRewriter&MockInterface + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->never(); + $pages->expects('apply')->never(); + + return $pages; + } + + private static function moduleOf(ModuleUsageQuery $query): string + { + return (new ReflectionProperty(ModuleUsageQuery::class, 'module'))->getValue($query); + } + + private static function onlyWithOf(AssignRewrite $rewrite): ?string + { + return (new ReflectionProperty(AssignRewrite::class, 'onlyWithPresetId'))->getValue($rewrite); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + $ability = $this->ability(self::untouched()); + Functions\when('current_user_can')->justReturn(false); + $input = ['module' => 'button', 'preset' => 'CTA primär']; + + self::assertToolError($ability->permission($input), 'forbidden'); + self::assertToolError($ability->execute($input), 'forbidden'); + } + + public function testUnknownPresetIsRefusedBeforeAnyPageIsRead(): void + { + $result = $this->ability(self::untouched())->execute(['module' => 'button', 'preset' => 'Fehlt']); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('preset', self::errorFields($result)); + } + + public function testOnlyWithAndOnlyWithoutPresetExcludeEachOther(): void + { + $result = $this->ability(self::untouched())->execute([ + 'module' => 'button', + 'preset' => 'CTA primär', + 'only_without_preset' => true, + 'only_with_preset' => 'aaaaaaaaaa', + ]); + + self::assertToolError($result, 'validation_failed'); + self::assertSame( + ['only_with_preset' => 'nicht zusammen mit only_without_preset'], + self::errorFields($result) + ); + } + + public function testNullAndMissingAssignmentPointToDetach(): void + { + $ability = $this->ability(self::untouched()); + + $null = $ability->execute(['module' => 'button', 'preset' => null]); + self::assertToolError($null, 'validation_failed'); + self::assertStringContainsString('lit/divi-design-detach', self::errorFields($null)['preset']); + + $groupNull = $ability->execute(['module' => 'text', 'group_presets' => ['font-body' => null]]); + self::assertStringContainsString( + 'lit/divi-design-detach', + self::errorFields($groupNull)['group_presets.font-body'] + ); + + $missing = $ability->execute(['module' => 'button']); + self::assertSame('preset oder group_presets angeben', self::errorFields($missing)['preset']); + } + + public function testBadModuleFiltersAndPostIdsAreCollected(): void + { + $ability = $this->ability(self::untouched()); + + $fields = self::errorFields($ability->execute([ + 'module' => 'Button!', + 'preset' => 'CTA primär', + 'post_ids' => [0], + 'clear_overrides' => 'ja', + ])); + self::assertSame(['module', 'post_ids', 'clear_overrides'], array_keys($fields)); + + $unknown = self::errorFields($ability->execute([ + 'module' => 'button', + 'preset' => 'CTA primär', + 'only_with_preset' => 'Fehlt', + ])); + self::assertSame('unbekanntes Modul-Preset divi/button „Fehlt“', $unknown['only_with_preset']); + } + + public function testDryRunListsThePagesAndWritesNothing(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with( + Mockery::on(static fn (ModuleUsageQuery $query): bool => self::moduleOf($query) === 'divi/button'), + Mockery::on(static fn (Closure $rewrite): bool => $rewrite() instanceof AssignRewrite), + null + ) + ->andReturn(PageRewriteReportTest::preview(2)); + $pages->expects('apply')->never(); + + $dry = self::assertToolError( + $this->ability($pages)->execute(['module' => 'button', 'preset' => ['CTA primär', 'cccccccccc']]), + 'confirm_required' + ); + + self::assertSame([12, 13], array_column($dry['pages'], 'post_id')); + self::assertSame(2, $dry['pages_count']); + self::assertStringStartsWith('Zuweisung an divi/button – betroffen (Seiten: 2, Module: 2).', $dry['message']); + self::assertStringContainsString('confirm: true', $dry['message']); + } + + public function testEmptyDryRunIsNothingToDo(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); + $pages->expects('apply')->never(); + + $result = $this->ability($pages)->execute(['module' => 'button', 'preset' => 'CTA primär']); + + self::assertTrue($result['ok']); + self::assertSame('Zuweisung an divi/button – keine Seite betroffen, nichts zu tun.', $result['summary']); + self::assertSame([[], 0], [$result['saved'], $result['remaining']]); + } + + public function testConfirmWritesAndReportsWhatRemains(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->never(); + $pages->expects('apply') + ->with( + 'lit/divi-presets-assign', + Mockery::on(static fn (ModuleUsageQuery $query): bool => self::moduleOf($query) === 'divi/button'), + Mockery::on(static fn (Closure $rewrite): bool => self::onlyWithOf($rewrite()) === 'aaaaaaaaaa'), + [12, 13], + 'Zuweisung an divi/button' + ) + ->andReturn(PageRewriteReportTest::applied(2, 3)); + + $result = $this->ability($pages)->execute([ + 'module' => 'divi/button', + 'preset' => ['bbbbbbbbbb', 'cccccccccc'], + 'only_with_preset' => 'CTA primär', + 'post_ids' => [12, 13], + 'confirm' => true, + ]); + + self::assertTrue($result['ok']); + self::assertSame(3, $result['remaining']); + self::assertSame([900, 901], array_column($result['saved'], 'revision_id')); + self::assertStringContainsString('Noch 3 Seiten offen', $result['summary']); + self::assertStringContainsString('confirm: true', $result['summary']); + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(self::untouched()); + $schema = $ability->inputSchema(); + + self::assertSame('lit/divi-presets-assign', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); + self::assertSame(['module'], $schema['required']); + self::assertFalse($schema['additionalProperties']); + self::assertSame(DiviPresetsAssignAbility::MAX_STACK, $schema['properties']['preset']['maxItems']); + self::assertArrayNotHasKey('design_rev', $schema['properties']); + self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3)); + self::assertStringContainsString('gewinnt das letzte Preset im Stapel', $ability->description()); + } +} +``` + +`EXPECTED` in `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` am Ende um `'lit/divi-presets-assign',` ergänzen +(nach `'lit/divi-design-restore',`). + +- [ ] **Step 4: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\PostIdsInput" not found`, ebenso für +`PageRewriteReport` und `DiviPresetsAssignAbility`; `DiviIntegrationTest` scheitert am fehlenden +`lit/divi-presets-assign`. + +- [ ] **Step 5: `PostIdsInput` und `PageRewriteReport` implementieren** + +`src/Integrations/Divi/Design/PostIdsInput.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Integrations\Divi\Design; + +use InvalidArgumentException; + +defined('ABSPATH') || exit; + +/** + * The post filter "post_ids" of the site-wide preset tools (lit/divi-presets-assign, lit/divi-design-detach): + * 1 to PageRewriter::PAGE_LIMIT positive post IDs – as many as one confirmed call writes. Pure PHP. + */ +final class PostIdsInput +{ + public const MAX = PageRewriter::PAGE_LIMIT; + + /** @return array<string, mixed> */ + public static function schema(): array + { + return [ + 'type' => 'array', + 'items' => ['type' => 'integer', 'minimum' => 1], + 'minItems' => 1, + 'maxItems' => self::MAX, + 'description' => 'Nur diese Beiträge (IDs); ohne Angabe alle Fundstellen.', + ]; + } + + /** + * @return list<int>|null null without a filter; duplicates removed. + * @throws InvalidArgumentException For anything but a list of 1 to MAX positive integers. + */ + public static function parse(mixed $value): ?array + { + if ($value === null) { + return null; + } + if (! is_array($value) || ! array_is_list($value) || $value === [] || count($value) > self::MAX) { + throw self::invalid(); + } + foreach ($value as $id) { + if (! is_int($id) || $id < 1) { + throw self::invalid(); + } + } + + return array_values(array_unique($value)); + } + + private static function invalid(): InvalidArgumentException + { + return new InvalidArgumentException(sprintf( + 'Liste von 1 bis %d Beitrags-IDs (positive ganze Zahlen) erwartet', + self::MAX + )); + } +} +``` + +`src/Integrations/Divi/Design/PageRewriteReport.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Integrations\Divi\Design; + +use LIT\MCP\Abilities\ToolResult; + +defined('ABSPATH') || exit; + +/** + * The words and the output schema around the PageRewriter (spec §6.6, A6), shared by lit/divi-presets-assign and + * lit/divi-design-detach: when a dry run has nothing to do, the dry run message with the next step, the summary of a + * confirmed call with "remaining". $preview is a PageRewriter::preview() result, $result an apply() result. Pure PHP. + */ +final class PageRewriteReport +{ + /** @param array<string, mixed> $preview */ + public static function isEmpty(array $preview): bool + { + return $preview['pages_count'] === 0 && $preview['blocked_ids'] === [] && $preview['hidden_count'] === 0; + } + + /** @param array<string, mixed> $preview */ + public static function dryRunMessage(array $preview, string $change): string + { + $message = sprintf( + '%s – betroffen (Seiten: %d, Module: %d). Nichts wurde gespeichert (Probelauf). Nach Rückfrage beim ' + . 'Nutzer mit confirm: true erneut aufrufen; je Aufruf werden höchstens %d Seiten geschrieben.', + $change, + $preview['pages_count'], + $preview['nodes_count'], + PageRewriter::PAGE_LIMIT + ); + if ($preview['blocked_ids'] !== []) { + $message .= ' Seiten unter blocked_ids darf dieses Konto nicht bearbeiten; mit confirm würde nichts ' + . 'gespeichert.'; + } + + return $message; + } + + /** @param array<string, mixed> $result */ + public static function savedSummary(array $result, string $change): string + { + $summary = sprintf( + '%s – gespeichert (Seiten: %d, Module: %d).', + $change, + count($result['saved']), + array_sum(array_column($result['saved'], 'nodes')) + ); + if ($result['remaining'] > 0) { + return $summary . sprintf( + ' Noch %d Seiten offen: mit denselben Angaben erneut mit confirm: true aufrufen.', + $result['remaining'] + ); + } + if ($result['skipped'] !== []) { + return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped'])); + } + + return $summary . ' Alle Fundstellen sind erledigt.'; + } + + /** + * @param array<string, mixed> $preview + * @param list<string> $warnings + * @return array<string, mixed> + */ + public static function nothingToDo(string $summary, array $preview, array $warnings = []): array + { + return ToolResult::ok($summary) + ->warnings($warnings) + ->with('saved', []) + ->with('remaining', 0) + ->with('skipped', $preview['skipped']) + ->toArray(); + } + + /** + * @param array<string, array<string, mixed>> $extra Further optional output properties. + * @return array<string, mixed> + */ + public static function outputSchema(array $extra = []): array + { + return ToolResult::schema([ + 'saved' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'revision_id' => ['type' => ['integer', 'null']], + 'nodes' => ['type' => 'integer'], + ], + 'required' => ['post_id', 'revision_id', 'nodes'], + ], + ], + 'remaining' => ['type' => 'integer'], + 'skipped' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['post_id' => ['type' => 'integer'], 'reason' => ['type' => 'string']], + 'required' => ['post_id', 'reason'], + ], + ], + ] + $extra, ['saved', 'remaining', 'skipped']); + } +} +``` + +- [ ] **Step 6: `DiviPresetsAssignAbility` implementieren** + +`src/Integrations/Divi/Design/DiviPresetsAssignAbility.php`: + +```php +<?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 WP_Error; + +defined('ABSPATH') || exit; + +/** + * lit/divi-presets-assign (spec §6.7, G2, A6): assigns a module preset stack and/or group presets to every module of + * one type – on all posts that contain it or only on post_ids. Every value passes PresetAssignment (choke point D7), + * AssignRewrite changes the blocks and the PageRewriter writes: without confirm a dry run, with confirm at most + * PageRewriter::PAGE_LIMIT posts, each with a revision; "remaining" counts the rest. The filters skip modules that + * already carry the change, so the same call continues where the last one stopped. An empty dry run is a normal + * result ("nichts zu tun"), not confirm_required: there is nothing to confirm, and a repeated call ends cleanly. + * null removes nothing here – lit/divi-design-detach does. + */ +final class DiviPresetsAssignAbility implements Ability +{ + public const MAX_STACK = 10; + + /** Path prefix for PresetAssignment's errors; removed again so fields name the input keys. */ + private const ROOT = 'input'; + + private const USE_DETACH = 'null entfernt site-weit nichts; Preset-Verweise entfernt lit/divi-design-detach'; + + /** @param Closure(): PresetAssignment $assignment A fresh assignment (own preset book) per call. */ + public function __construct( + private DesignSystemWriter $writer, + private PageRewriter $pages, + private Closure $assignment + ) { + } + + public function name(): string + { + return 'lit/divi-presets-assign'; + } + + public function label(): string + { + return 'Divi-Presets site-weit zuweisen'; + } + + public function description(): string + { + return 'Weist allen Modulen eines Typs (module, z. B. "button") site-weit ein Modul-Preset (preset: Name oder ' + . 'ID oder Stapel als Liste; bei gleicher Priorität gewinnt das letzte Preset im Stapel (Divi-Verhalten)) ' + . 'und/oder Gruppen-Presets (group_presets) zu. Filter: post_ids, only_without_preset oder ' + . 'only_with_preset; clear_overrides: true entfernt lokale Werte, die das Preset überdecken. Zuerst ' + . 'Probelauf (betroffene Seiten und Module), nach Rückfrage beim Nutzer confirm: true; je Aufruf höchstens ' + . '50 Seiten, jede mit Revision; remaining nennt den Rest. Verweise entfernen: lit/divi-design-detach.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'], + 'preset' => [ + 'type' => ['string', 'array'], + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => self::MAX_STACK, + 'description' => 'Modul-Preset (Name oder ID) oder Stapel; bei gleicher Priorität gewinnt das ' + . 'letzte Preset im Stapel (Divi-Verhalten).', + ], + 'group_presets' => [ + 'type' => 'object', + 'description' => '{"<Gruppe>": "<Name>" | ["<Name>", …]}; Gruppe als Kurzname (font-body), voller ' + . 'Name (divi/font-body) oder Attributpfad.', + ], + 'post_ids' => PostIdsInput::schema(), + 'only_without_preset' => [ + 'type' => 'boolean', + 'description' => 'Nur Module ohne eigenes Modul-Preset.', + ], + 'only_with_preset' => [ + 'type' => 'string', + 'description' => 'Nur Module mit diesem Modul-Preset (Name oder ID); nicht zusammen mit ' + . 'only_without_preset.', + ], + 'clear_overrides' => [ + 'type' => 'boolean', + 'description' => 'Lokale Werte entfernen, die die zugewiesenen Presets überdecken.', + ], + ] + array_intersect_key(DesignChange::inputProperties(), ['confirm' => true]), + 'required' => ['module'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return PageRewriteReport::outputSchema(); + } + + 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; + } + $request = $this->request($input); + if ($request instanceof WP_Error) { + return $request; + } + + $query = new ModuleUsageQuery($request['module']); + $change = 'Zuweisung an ' . $request['module']; + if (($input['confirm'] ?? false) !== true) { + return $this->dryRun($query, $request, $change); + } + + $result = $this->pages->apply($this->name(), $query, $request['rewrite'], $request['post_ids'], $change); + if ($result instanceof WP_Error) { + return $result; + } + + return ToolResult::ok(PageRewriteReport::savedSummary($result, $change)) + ->warnings($result['warnings']) + ->with('saved', $result['saved']) + ->with('remaining', $result['remaining']) + ->with('skipped', $result['skipped']) + ->toArray(); + } + + /** + * @param array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list<int>|null} $request + * @return array<string, mixed>|WP_Error + */ + private function dryRun(ModuleUsageQuery $query, array $request, string $change): array|WP_Error + { + $preview = $this->pages->preview($query, $request['rewrite'], $request['post_ids']); + if ($preview instanceof WP_Error) { + return $preview; + } + if (PageRewriteReport::isEmpty($preview)) { + return PageRewriteReport::nothingToDo($change . ' – keine Seite betroffen, nichts zu tun.', $preview); + } + + return Errors::confirmRequired(PageRewriteReport::dryRunMessage($preview, $change), $preview); + } + + /** + * The checked call: full module name, the rewrite strategy per page and the post filter. + * + * @param array<string, mixed> $input + * @return array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list<int>|null}|WP_Error + */ + private function request(array $input): array|WP_Error + { + $errors = []; + $module = self::module($input['module'] ?? null, $errors); + $spec = self::spec($input, $errors); + $postIds = self::postIds($input['post_ids'] ?? null, $errors); + $onlyWithout = self::flag($input, 'only_without_preset', $errors); + $clear = self::flag($input, 'clear_overrides', $errors); + if ($module === null || $errors !== []) { + return self::invalid($errors); + } + + $assignment = ($this->assignment)(); + foreach ($assignment->apply($module, [], $spec, self::ROOT)['errors'] as $path => $problem) { + $errors[self::field($path)] = $problem; + } + $onlyWith = $this->onlyWith($module, $input, $onlyWithout, $errors); + if ($errors !== []) { + return self::invalid($errors); + } + + return [ + 'module' => $module, + 'post_ids' => $postIds, + 'rewrite' => static fn (): NodeRewrite => new AssignRewrite( + $module, + $assignment, + $spec, + $onlyWithout, + $onlyWith, + $clear + ), + ]; + } + + /** @param array<string, string> $errors */ + private static function module(mixed $value, array &$errors): ?string + { + try { + return PresetScope::fromInput([PresetScope::MODULE => $value], 'module')->name; + } catch (InvalidArgumentException) { + $errors['module'] = 'Modulname wie "button" oder "divi/button" erwartet'; + + return null; + } + } + + /** + * preset and group_presets as given; null removes nothing site-wide (lit/divi-design-detach does). + * + * @param array<string, mixed> $input + * @param array<string, string> $errors + * @return array<string, mixed> + */ + private static function spec(array $input, array &$errors): array + { + $spec = array_intersect_key($input, ['preset' => true, 'group_presets' => true]); + if ($spec === []) { + $errors['preset'] = 'preset oder group_presets angeben'; + } + if (array_key_exists('preset', $spec) && $spec['preset'] === null) { + $errors['preset'] = self::USE_DETACH; + } + $groups = $spec['group_presets'] ?? null; + if ($groups === []) { + $errors['group_presets'] = 'mindestens eine Gruppe angeben'; + } + foreach (is_array($groups) ? $groups : [] as $key => $value) { + if ($value === null) { + $errors['group_presets.' . $key] = self::USE_DETACH; + } + } + + return $spec; + } + + /** + * @param array<string, string> $errors + * @return list<int>|null + */ + private static function postIds(mixed $value, array &$errors): ?array + { + try { + return PostIdsInput::parse($value); + } catch (InvalidArgumentException $e) { + $errors['post_ids'] = $e->getMessage(); + + return null; + } + } + + /** + * @param array<string, mixed> $input + * @param array<string, string> $errors + */ + private static function flag(array $input, string $key, array &$errors): bool + { + $value = $input[$key] ?? false; + if (! is_bool($value)) { + $errors[$key] = 'true oder false erwartet'; + + return false; + } + + return $value; + } + + /** + * The ID of the module preset only_with_preset names; null without that filter or after noting a problem. + * + * @param array<string, mixed> $input + * @param array<string, string> $errors + */ + private function onlyWith(string $module, array $input, bool $onlyWithout, array &$errors): ?string + { + $value = $input['only_with_preset'] ?? null; + if ($value === null) { + return null; + } + if ($onlyWithout) { + $errors['only_with_preset'] = 'nicht zusammen mit only_without_preset'; + + return null; + } + + $scope = new PresetScope(PresetScope::MODULE, $module); + $name = is_string($value) ? $value : ''; + try { + $found = PresetBook::fromDivi($this->writer->presets())->find($scope, $name); + } catch (InvalidArgumentException $e) { + $errors['only_with_preset'] = $e->getMessage(); + + return null; + } + if ($found === null) { + $errors['only_with_preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $name); + + return null; + } + + return (string) $found['id']; + } + + private static function field(string $path): string + { + $prefix = self::ROOT . '.'; + + return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; + } + + /** @param array<string, string> $errors */ + private static function invalid(array $errors): WP_Error + { + return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors); + } +} +``` + +- [ ] **Step 7: In `DiviIntegration` registrieren** + +In `src/Integrations/Divi/DiviIntegration.php` die Imports ergänzen (alphabetisch in den Design-Block): + +```php +use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +``` + +In `abilities()` nach `new DiviDesignRestoreAbility(…),` als letzten Eintrag: + +```php + new DiviPresetsAssignAbility( + $this->designWriter, + $this->pageRewriter($loader), + $this->presetAssignment(...) + ), +``` + +und nach `presetComposer()` die Fabrik (Signatur-Annahme 4; hat Task 4 bereits eine Fabrik in `DiviIntegration` +angelegt, diese verwenden und keine zweite anlegen): + +```php + /** A fresh PresetAssignment (own preset book) per lit/divi-presets-assign call, wired like the Composer's (D7). */ + private function presetAssignment(): PresetAssignment + { + return $this->composition()->presets(); + } +``` + +- [ ] **Step 8: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 9: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Meldungen. + +```bash +git add src/Integrations/Divi/Design/PostIdsInput.php src/Integrations/Divi/Design/PageRewriteReport.php src/Integrations/Divi/Design/DiviPresetsAssignAbility.php src/Integrations/Divi/Design/PageRewriter.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +``` + +Commit wie in den Global Constraints mit der Nachricht +`feat: lit/divi-presets-assign – Presets site-weit zuweisen mit Probelauf und höchstens 50 Seiten je Aufruf`. +(`PageRewriter.php` nur stagen, wenn Step 1 ihn geändert hat.) + +--- + +### Task 10: `lit/divi-design-detach` und `PresetBook::withoutNestedRef()` + +**Files:** +- Modify: `src/Integrations/Divi/Design/PresetBook.php` (`withoutNestedRef()`) +- Modify: `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` +- Create: `src/Integrations/Divi/Design/DiviDesignDetachAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php` + +`DiviPresetsDeleteAbility::DETACH_HINT` bleibt unverändert (Task 6 entfernt „(ab Etappe 4b)“). + +**Interfaces:** +- Consumes: `DesignChange::run()`, `DesignChange::inputProperties()`, `DesignPlan`, `PresetArea`, `PresetBook` + (`find()`, `defaultId()`, `nestedUsers()`, `markupOutside()`), `PresetScope::fromInput()`, `PresetDiff`, + `PresetUsageQuery`, `GlobalUsages::reportFor()`, `DesignRights::presets()/markup()`, `DetachRewrite` (Task 7), + `PageRewriter::preview()/apply()` (Task 8), `PostIdsInput`, `PageRewriteReport` (Task 9), + `DiviIntegration::pageRewriter()` (Task 8). +- Produces: + - `PresetBook::withoutNestedRef(string $groupPresetId): self` (Gerüst). + - `final class DiviDesignDetachAbility implements Ability { public function __construct(DesignSystemWriter $writer, + DesignChange $change, GlobalUsages $usages, PageRewriter $pages, ?Closure $unfiltered = null) }`. + +Eingabe (Spec §6.8, R10): `preset` = `{module | group, preset}` (Form wie ein Eintrag von +`lit/divi-presets-delete`), `variable` (→ `unsupported`), `post_ids`, `confirm`, `design_rev`. + +Ablauf und Entscheidungen: +- **Auflösen:** Form-/Namensfehler (`PresetScope::fromInput`) und mehrdeutige Namen (IDs in der Meldung, S13a) → + `validation_failed` wie bei `lit/divi-presets-delete`; ein unbekanntes Preset → `not_found` mit Verweis auf + `lit/divi-design-system`. +- **Verschachtelt** heißt: `post_ids` fehlt und `PresetBook::nestedUsers($id)` ist nicht leer (nur Gruppen-Presets + werden verschachtelt). Die Seitenfundstellen sucht `PresetUsageQuery([{scope, id, default: false}])` – nur + ausdrückliche Verweise, auch beim Standard-Preset. +- **Probelauf = EIN `confirm_required`:** Details = `PageRewriter::preview()` + bei Verschachtelung `diff` und + `design_rev` des Preset-Schritts + `warnings`. Die Preset-Daten kommen aus `DesignChange::run()` ohne `confirm`: + Es liefert `confirm_required` mit `data.diff`/`data.design_rev` (übernommen), ein `ToolResult` mit leerem Diff + (Verweise inzwischen weg → kein Preset-Schritt) oder einen anderen Fehler (z. B. `forbidden` wegen HTML/CSS + anderer Presets → sofort zurück). Leerer Probelauf ohne Preset-Schritt → normales Ergebnis „nichts zu tun“ wie in + Task 9, mit dem Lösch-Hinweis, wenn kein `post_ids` und nichts übersprungen. +- **Mit `confirm`:** Preset-Schritt zuerst über `DesignChange::run()` (Lock, `design_rev`, Snapshot, Rücklesen, + Protokoll). Sein Plan trägt den `UsageReport` der Seitenfundstellen (`DesignPlan::$usages`), damit eine blockierte + Live-Seite (P8a, `HARD_CAP`) schon **vor** dem Preset-Schreiben `forbidden` auslöst. Jeder Fehler (`rev_mismatch`, + `conflict`, `forbidden`, `validation_failed` wegen fehlender `design_rev`) → sofort zurück, keine Seite geschrieben. + Danach `PageRewriter::apply()`. Scheitert dieser nach geschriebenem Preset-Schritt, bekommt der Fehler den Satz + „Die Verweise in anderen Presets sind bereits entfernt (Snapshot …)“ sowie `snapshot_id` und `diff`. +- **Markup (I-2):** Der Preset-Schritt speichert den ganzen Bestand; ohne `unfiltered_html` wird er abgelehnt, sobald + irgendein Preset HTML/CSS trägt (`markupOutside([])`, wie geänderte Presets in `lit/divi-presets-set`). +- **Standard-Preset:** Hinweis in `warnings` (Probelauf und Ergebnis): Divi wendet es weiter implizit an, entfernt + werden nur ausdrückliche Verweise. +- **Abschluss:** `remaining` 0, nichts übersprungen und kein `post_ids` → Zusammenfassung endet mit „Jetzt kann + lit/divi-presets-delete das Preset löschen.“. Weitere Aufrufe (bei `remaining` > 0) finden keine verschachtelten + Verweise mehr und brauchen keine `design_rev`. +- `Annotations::write(true)` (Global Constraints: neue Tools `write`; Wiederholen ändert nichts mehr). + +- [ ] **Step 1: Failing tests für `PresetBook::withoutNestedRef()` schreiben** + +In `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` den Import +`use LIT\MCP\Integrations\Divi\Design\PresetDiff;` ergänzen und anhängen: + +```php + public function testWithoutNestedRefRemovesTheIdFromEveryStack(): void + { + $stored = self::stored(); + $stored['module']['divi/button']['items']['aaaaaaaaaa']['groupPresets'] = [ + 'button.decoration.font' => ['presetId' => ['hhhhhhhhhh', 'gggggggggg'], 'groupName' => 'divi/font'], + ]; + $book = PresetBook::fromDivi($stored); + + $after = $book->withoutNestedRef('gggggggggg'); + + self::assertSame([], $after->nestedUsers('gggggggggg')); + self::assertSame([], $after->items(new PresetScope('module', 'divi/text'))['dddddddddd']['groupPresets']); + self::assertSame( + ['button.decoration.font' => ['presetId' => ['hhhhhhhhhh'], 'groupName' => 'divi/font']], + $after->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa']['groupPresets'] + ); + self::assertCount(2, $book->nestedUsers('gggggggggg'), 'immutable'); + self::assertSame( + [['dddddddddd', 'changed', ['groupPresets']], ['aaaaaaaaaa', 'changed', ['groupPresets']]], + array_map( + static fn (array $row): array => [$row['id'], $row['action'], $row['fields']], + array_reverse(PresetDiff::between($book, $after)) + ) + ); + } + + public function testWithoutNestedRefTakesAStringIdAndLeavesOthersAlone(): void + { + $book = PresetBook::fromDivi(self::stored()); + self::assertSame($book->rev(), $book->withoutNestedRef('zzzzzzzzzz')->rev()); + + $stored = self::stored(); + $text = &$stored['module']['divi/text']['items']['dddddddddd']; + $text['groupPresets']['content.decoration.bodyFont']['presetId'] = 'gggggggggg'; + unset($text); + + self::assertSame([], PresetBook::fromDivi($stored)->withoutNestedRef('gggggggggg')->nestedUsers('gggggggggg')); + } +``` + +(`PresetDiff::between()` sortiert nach Bereich: `divi/button` vor `divi/text`; `array_reverse` stellt die +Reihenfolge des Assertions-Arrays her.) + +- [ ] **Step 2: Failing tests für `lit/divi-design-detach` schreiben** + +`tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php`: + +```php +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; + +use Brain\Monkey\Functions; +use Closure; +use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\Design\DesignChange; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\DetachRewrite; +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\PageRewriter; +use LIT\MCP\Integrations\Divi\Design\PresetBook; +use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Integrations\Divi\Library\UsageReport; +use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use Mockery\MockInterface; +use ReflectionProperty; + +final class DiviDesignDetachAbilityTest extends TestCase +{ + private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext']; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('current_user_can')->justReturn(true); + // DesignLock (final review I-5): the lock is always free here. + Functions\when('add_option')->justReturn(true); + Functions\when('delete_option')->justReturn(true); + } + + private function ability( + PageRewriter $pages, + ?DesignSystemWriter $writer = null, + ?SnapshotStore $snapshots = null, + ?FakeChangeLog $log = null + ): DiviDesignDetachAbility { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); + + return new DiviDesignDetachAbility( + $writer ?? self::readOnlyWriter(), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()), + $usages, + $pages, + static fn (): bool => true + ); + } + + /** Reads PresetBookTest::stored(), never saves. */ + private static function readOnlyWriter(): DesignSystemWriter&MockInterface + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets')->never(); + + return $writer; + } + + private static function untouched(): PageRewriter&MockInterface + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->never(); + $pages->expects('apply')->never(); + + return $pages; + } + + /** Matches the explicit-only query and a DetachRewrite strategy of a preview()/apply() call. */ + private static function explicitQuery(): Closure + { + return static function (PresetUsageQuery $query): bool { + $targets = (new ReflectionProperty(PresetUsageQuery::class, 'targets'))->getValue($query); + + return array_column($targets, 'default') === [false]; + }; + } + + private static function detaching(): Closure + { + return static fn (Closure $rewrite): bool => $rewrite() instanceof DetachRewrite; + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + $ability = $this->ability(self::untouched()); + Functions\when('current_user_can')->justReturn(false); + + self::assertToolError($ability->permission(['preset' => self::LESETEXT]), 'forbidden'); + self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden'); + } + + public function testVariablesFollowWithStage4c(): void + { + $error = self::assertToolError( + $this->ability(self::untouched())->execute(['variable' => 'Eckenradius']), + 'unsupported' + ); + + self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']); + } + + public function testUnknownAndAmbiguousPresets(): void + { + $ability = $this->ability(self::untouched()); + + $unknown = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Fehlt']]); + self::assertToolError($unknown, 'not_found'); + $ambiguous = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Doppelt']]); + self::assertToolError($ambiguous, 'validation_failed'); + self::assertStringContainsString('bbbbbbbbbb, cccccccccc', self::errorFields($ambiguous)['preset.preset']); + $form = $ability->execute(['preset' => ['preset' => 'Lesetext']]); + self::assertArrayHasKey('preset', self::errorFields($form)); + } + + public function testDryRunListsPagesAndTheDiffOfTheNestingPresets(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null) + ->andReturn(PageRewriteReportTest::preview(1)); + $pages->expects('apply')->never(); + + $dry = self::assertToolError($this->ability($pages)->execute(['preset' => self::LESETEXT]), 'confirm_required'); + + self::assertSame(12, $dry['pages'][0]['post_id']); + self::assertSame([['dddddddddd', 'changed']], array_map( + static fn (array $row): array => [$row['id'], $row['action']], + $dry['diff'] + )); + self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $dry['design_rev']); + self::assertStringContainsString('design_rev', $dry['message']); + self::assertSame([], $dry['warnings']); + } + + public function testConfirmWithAStaleDesignRevWritesNeitherPresetsNorPages(): void + { + $error = self::assertToolError( + $this->ability(self::untouched())->execute([ + 'preset' => self::LESETEXT, + 'confirm' => true, + 'design_rev' => 'veraltet', + ]), + 'rev_mismatch' + ); + + self::assertStringContainsString('nichts wurde gespeichert', $error['message']); + } + + public function testConfirmRemovesTheNestedReferencesFirstThenThePages(): void + { + $calls = []; + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets') + ->with( + Mockery::on(static fn (array $rest): bool => $rest['module'][1]['items'][0]['groupPresets'] === []), + false + ) + ->andReturnUsing(static function () use (&$calls): array { + $calls[] = 'presets'; + + return PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data(); + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-4'); + $snapshots->expects('attachLog'); + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(1)); + $pages->expects('apply') + ->with( + 'lit/divi-design-detach', + Mockery::on(self::explicitQuery()), + Mockery::on(self::detaching()), + null, + 'Entkoppeln von Gruppen-Preset divi/font-body „Lesetext“' + ) + ->andReturnUsing(static function () use (&$calls): array { + $calls[] = 'pages'; + + return PageRewriteReportTest::applied(1, 0); + }); + $ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7)); + + $dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'confirm_required'); + $result = $ability->execute([ + 'preset' => self::LESETEXT, + 'confirm' => true, + 'design_rev' => $dry['design_rev'], + ]); + + self::assertSame(['presets', 'pages'], $calls); + self::assertSame('presets-4', $result['snapshot_id']); + self::assertSame(7, $result['log_id']); + self::assertSame('dddddddddd', $result['diff'][0]['id']); + self::assertSame(12, $result['saved'][0]['post_id']); + self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']); + self::assertStringEndsWith('Jetzt kann lit/divi-presets-delete das Preset löschen.', $result['summary']); + } + + public function testAPageErrorAfterThePresetStepSaysThePresetsAreChanged(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets') + ->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data()); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-4'); + $snapshots->expects('attachLog'); + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply')->andReturn(Errors::partialWrite('Seite 13 nicht gespeichert.', 13)); + $rev = PresetBook::fromDivi(PresetBookTest::stored())->rev(); + + $error = self::assertToolError( + $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7))->execute([ + 'preset' => self::LESETEXT, + 'confirm' => true, + 'design_rev' => $rev, + ]), + 'partial_write' + ); + + self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']); + self::assertSame('presets-4', $error['snapshot_id']); + self::assertSame(13, $error['post_id']); + } + + public function testPostIdsTouchOnlyThePages(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), [12]) + ->andReturn(PageRewriteReportTest::preview(1)); + $pages->expects('apply') + ->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string')) + ->andReturn(PageRewriteReportTest::applied(1, 0)); + $ability = $this->ability($pages); + $input = ['preset' => self::LESETEXT, 'post_ids' => [12]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertArrayNotHasKey('diff', $dry); + + $result = $ability->execute($input + ['confirm' => true]); + self::assertSame([], $result['diff']); + self::assertNull($result['design_rev']); + self::assertNull($result['snapshot_id']); + self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); + } + + public function testADefaultPresetLosesOnlyItsExplicitReferences(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null) + ->andReturn(PageRewriteReportTest::preview(1)); + + $dry = self::assertToolError( + $this->ability($pages)->execute(['preset' => ['module' => 'text', 'preset' => 'Fließtext']]), + 'confirm_required' + ); + + self::assertSame( + ['„Fließtext“ ist das Standard-Preset von divi/text: Divi wendet es weiter auf Module ohne eigenes ' + . 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'], + $dry['warnings'] + ); + self::assertArrayNotHasKey('diff', $dry, 'module presets are never nested'); + } + + public function testNothingLeftIsNoConfirmAndPointsToDelete(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); + + $result = $this->ability($pages)->execute(['preset' => ['module' => 'button', 'preset' => 'CTA primär']]); + + self::assertTrue($result['ok']); + self::assertSame( + 'Keine Verweise auf Modul-Preset divi/button „CTA primär“ gefunden – nichts zu tun. Jetzt kann ' + . 'lit/divi-presets-delete das Preset löschen.', + $result['summary'] + ); + } + + public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(2, 2)); + + $result = $this->ability($pages)->execute([ + 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], + 'confirm' => true, + ]); + + self::assertSame(2, $result['remaining']); + self::assertStringContainsString('Noch 2 Seiten offen', $result['summary']); + self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(self::untouched()); + $schema = $ability->inputSchema(); + + self::assertSame('lit/divi-design-detach', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); + self::assertFalse($schema['additionalProperties']); + self::assertSame(['preset'], $schema['properties']['preset']['required']); + self::assertArrayHasKey('design_rev', $schema['properties']); + self::assertArrayHasKey('variable', $schema['properties']); + self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3)); + self::assertStringContainsString('lit/divi-presets-delete', $ability->description()); + } +} +``` + +`EXPECTED` in `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` am Ende um `'lit/divi-design-detach',` ergänzen +(nach `'lit/divi-presets-assign',`). + +- [ ] **Step 3: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetBookTest.php tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +Expected: FAIL – `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\PresetBook::withoutNestedRef()`, +`Error: Class "LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility" not found`; `DiviIntegrationTest` am +fehlenden `lit/divi-design-detach`. + +- [ ] **Step 4: `PresetBook::withoutNestedRef()` implementieren** + +In `src/Integrations/Divi/Design/PresetBook.php` nach `nestedUsers()` einfügen: + +```php + /** + * 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); + } +``` + +und bei den privaten Helfern (vor `hasMarkup()`): + +```php + /** + * @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; + } +``` + +- [ ] **Step 5: `DiviDesignDetachAbility` implementieren** + +`src/Integrations/Divi/Design/DiviDesignDetachAbility.php`: + +```php +<?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. 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, + '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); + } +} +``` + +- [ ] **Step 6: In `DiviIntegration` registrieren** + +Import ergänzen: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +``` + +In `abilities()` nach `new DiviPresetsAssignAbility(…),` als letzten Eintrag: + +```php + new DiviDesignDetachAbility( + $this->designWriter, + $this->designChange(), + $this->globalUsages(), + $this->pageRewriter($loader), + DesignRights::unfilteredHtml() + ), +``` + +- [ ] **Step 7: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 8: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Meldungen. + +```bash +git add src/Integrations/Divi/Design/PresetBook.php src/Integrations/Divi/Design/DiviDesignDetachAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +``` + +Commit wie in den Global Constraints mit der Nachricht +`feat: lit/divi-design-detach – Preset-Verweise aus Seiten und verschachtelten Presets entfernen`. + +--- + +## Offene Punkte (Tasks 9–10) + +1. **`PageRewriter` nicht `final`** (Signatur-Änderung 1) – der Gerüst-Autor bzw. Task 8 muss zustimmen; ohne diese + Änderung sind die Ability-Tests nur mit echtem `DiviPageLoader` (final) und WordPress-Stubs baubar. +2. **Fabrik für `PresetAssignment`** (Annahme 4): Task 9 ruft `DiviComposition::presets()` auf. Bei anderem + Namen aus Task 4 nur `DiviIntegration::presetAssignment()` anpassen. +3. **`PresetAssignment`-Fehlerpfade:** Die Tests erwarten für einen unbekannten Einzelnamen den Feldschlüssel + `preset` (Pfad `input.preset` gemäß Gerüst-Signatur „"$path.preset"“). Liefert Task 3 bei Stapeln andere + Indizes, ändern sich nur Feldnamen, keine Tests dieses Teils. +4. **`only_with_preset` und Stapel:** `AssignRewrite` entscheidet, ob „mit Preset X“ heißt „X irgendwo im Stapel“ + oder „X allein“; das Tool reicht nur die ID weiter. Für den Guide (Task 11) festhalten. +5. **Annotations:** Beide Tools `Annotations::write(true)` gemäß Global Constraints. Fachlich entfernt detach Verweise + und `clear_overrides` lokale Werte – `destructive(true)` wäre vertretbar (Spalte „Art“ im Tools-Tab). Entscheidung + des Gerüst-Autors; ändert nur `annotations()` und je eine Assertion. +6. **`updated`-Zeitstempel:** `withoutNestedRef()` ändert `groupPresets`, setzt aber `updated` des Presets nicht neu + (der Builder tut das beim Speichern). `PresetDiff` ignoriert `updated`; im Smoke (Task 11) prüfen, ob Divi den + Bestand ohne neuen Zeitstempel unverändert annimmt. +7. **Lock nur für den Preset-Schritt:** `DesignLock` deckt nur die Presets ab; die Seiten schreibt der `PageRewriter` + ohne Bereichs-Lock (je Seite frisch gelesen, Spec §6.6). Zwei parallele detach-/assign-Aufrufe auf dieselbe Seite + sind damit nicht gegeneinander gesperrt – wie bei den übrigen Divi-Schreib-Tools. +8. **Probelauf bei blockierten Seiten:** assign/detach liefern dann `confirm_required` mit `blocked_ids` und dem + Satz „mit confirm würde nichts gespeichert“, statt sofort `forbidden` – wie `DesignChange`. Soll der Probelauf + gleich `forbidden` liefern, betrifft das nur `PageRewriteReport::dryRunMessage()` und die Ability-Zweige. +9. **Grenze `post_ids`:** auf `PageRewriter::PAGE_LIMIT` (50) gesetzt, damit ein Aufruf mit `post_ids` immer + vollständig schreibt; mehr IDs → `validation_failed`. + +--- + +### Task 11: Guides, Entscheidungsnotiz, Integrations-Smoke + +**Files:** +- Modify: `docs/guides/divi-shortform.md` +- Modify: `docs/guides/divi-placeholders.md` +- Modify: `docs/guides/divi-design-system.md` +- Modify: `docs/guides/divi-structure.md` +- Modify: `docs/guides/workflows.md` +- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` +- Create: `tests/Integration/divi-presets-assign-smoke.sh` + +**Interfaces:** +- Consumes: alles aus Tasks 1–10 (Stapel/`group_presets`/`clear_overrides` in `lit-divi-insert`, `lit-divi-update`, `lit-content-create`; Outline-Feld `group_presets`; `lit/divi-presets-assign`; `lit/divi-design-detach`; `PageRewriter`-Antwortformen); `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`). +- Produces: fünf ergänzte Guides, Nachtrag „Etappe 4b“ in der Entscheidungsnotiz, Smoke `divi-presets-assign-smoke.sh` (endet mit `ALL CHECKS PASSED`). +- Regeln für die Guide-Texte (`tests/Unit/Guides/ToolNamingTest.php`): indirekte Tools immer `lit/<name>` (`lit/divi-presets-assign`, `lit/divi-design-detach`, `lit/divi-presets-delete`, `lit/divi-presets-set`), direkte immer `lit-<name>`; Fehlercodes ohne `lit_`-Präfix (`validation_failed`, `confirm_required`, `rev_mismatch`, `partial_write`, `unsupported`, `in_use`). `docs/guides/overview.md` bleibt unverändert (keine neuen Themen, keine neuen direkten Tools). + +Die Texte unten sind vollständig. „Vorher“ ist die Stelle, die mit `Edit` (`old_string`) gesucht wird, „Nachher“ der Ersatz. Zeilenumbrüche der Guides sind weich (Markdown); die Zeilenlänge ist nicht begrenzt. + +- [ ] **Step 1: `docs/guides/divi-shortform.md` ergänzen** + +**1a. Einleitung (Zeilen 3–7).** Vorher: + +```markdown +Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in +`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label` +(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe +`divi-attrs`). Kurz- und Vollform lassen sich kombinieren, aber nicht für denselben Pfad (dann Fehler +`validation_failed` mit dem Pfad). +``` + +Nachher: + +```markdown +Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in +`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label` +(Admin-Label), `preset` (Name oder Liste von Namen von Modul-Presets aus `lit-divi-design-system`), +`group_presets` (Gruppen-Presets, siehe „Presets zuweisen“) und `attrs` (Vollform, siehe `divi-attrs`). Kurz- und +Vollform lassen sich kombinieren, aber nicht für denselben Pfad (dann Fehler `validation_failed` mit dem Pfad). +``` + +**1b. Neuer Abschnitt „Presets zuweisen“.** Einfügen direkt vor der Überschrift `## Einfügen, ändern, verschieben, löschen` (also nach dem Absatz „Zwei Felder heißen wie Knoten-Schlüssel …“). Vorher (Anker, bleibt erhalten): + +```markdown +## Einfügen, ändern, verschieben, löschen +``` + +Nachher (neuer Abschnitt plus der Anker): + +````markdown +## Presets zuweisen + +Presets (Namen aus `lit-divi-design-system`, Modul-Presets mit `kind: module`, Gruppen-Presets mit `kind: group`) +gehen in `lit-divi-insert` (je Knoten in `nodes`), `lit-content-create` (je Knoten in `divi`) und +`lit-divi-update` (je Eintrag in `changes`) in drei Feldern: + +- `preset` – ein Name **oder eine Liste von 1–10 Namen** (Stapel). Der Stapel wird in dieser Reihenfolge + gespeichert; bei gleicher Priorität gewinnt das letzte Preset im Stapel (Divi-Verhalten). `null` entfernt das + Preset. Eine leere Liste und doppelte Namen sind Fehler (`validation_failed`); wer das Preset entfernen will, + sendet `null`. +- `group_presets` – Objekt `{"<Gruppe>": "<Name>" | ["<Name>", …] | null}`. Die Gruppe nennt man als Kurzname + (`font-body`), mit vollem Namen (`divi/font-body`) oder als Attributpfad (`content.decoration.bodyFont`). Hat + das Modul mehrere Gruppen mit demselben Namen (z. B. `spacing` für Modul und Button), nennt der Fehler die + Pfade zur Auswahl – dann den Pfad verwenden. Gibt es die Gruppe am Modul nicht oder das Preset in dieser + Gruppe nicht, kommt `validation_failed` mit dem Eingabepfad. `null` entfernt den Eintrag dieser Gruppe. +- `clear_overrides` – `true` oder `false` (nichts anderes), siehe „Überdeckte Werte“. + +Gruppen-Presets und Namen kommen immer aus Divi; die Werkzeuge schreiben nie frei erfundene IDs. + +Einfügen mit Stapel und Gruppen-Preset: + +```json +{ "type": "button", "text": "Termin buchen", "url": "{{link:42}}", + "preset": ["CTA primär", "CTA groß"], + "group_presets": { "font": "Button-Schrift" } } +``` + +Ändern mit `lit-divi-update` (Stapel ersetzen, Gruppen-Preset einer anderen Gruppe entfernen): + +```json +{ "post_id": 12, "tree_rev": "…", + "changes": [ { "id": "s0.r0.c1.m2", + "preset": ["CTA primär"], + "group_presets": { "font": "Button-Schrift", "spacing": null } } ] } +``` + +### Überdeckte Werte + +Lokale Werte eines Moduls (`attrs`, `set`) haben Vorrang vor den Werten seiner Presets. Weist ein Aufruf +ausdrücklich Presets zu (oder liegen sie schon am Modul), prüft das Werkzeug Blatt für Blatt (Pfad, Breakpoint, +Zustand): weicht ein lokaler Wert von dem des Presets ab, steht in `warnings` je Preset eine Zeile wie + +> `s0.r0.c1.m2: lokale Werte überdecken „CTA primär“: button.decoration.background – clear_overrides: true entfernt sie.` + +Die Zeile nennt bis zu fünf Pfade und „und N weitere“. Das Preset wirkt dort nicht; dem Nutzer sagen und fragen, +ob die lokalen Werte weg sollen. Dann denselben Knoten mit `"clear_overrides": true` ändern (z. B. +`{"id": "s0.r0.c1.m2", "preset": "CTA primär", "clear_overrides": true}`): das entfernt genau die lokalen Werte, +die ein ausdrücklich zugewiesenes Preset überdecken, und sonst nichts. Beim Einfügen wirkt `clear_overrides` nach +`set` und `attrs` desselben Knotens, entfernt also auch Werte, die der Aufruf selbst gesetzt hat. Standard-Presets +zählen nicht (Divi wendet sie implizit an, ohne dass der Knoten sie nennt). + +Dasselbe Preset auf vielen Seiten oder Modulen setzen: `lit/divi-presets-assign` (siehe `divi-design-system`). +```` + +**1c. `lit-divi-update` in „Einfügen, ändern …“ (Zeilen 79–81).** Vorher: + +```markdown +- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA + primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. Denselben + Pfad nicht zugleich über `set` und `attrs` ändern (dann zwei Änderungen senden). +``` + +Nachher: + +```markdown +- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA + primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. Neben + `preset` nehmen die Einträge `group_presets` und `clear_overrides` (siehe „Presets zuweisen“). Denselben + Pfad nicht zugleich über `set` und `attrs` ändern (dann zwei Änderungen senden). +``` + +**1d. Warnungen (Zeilen 119–121).** Vorher: + +```markdown +Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert; +Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer +nennen und möglichst gleich beheben. +``` + +Nachher: + +```markdown +Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert; +Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul; lokale Werte, die ein +zugewiesenes Preset überdecken (siehe „Überdeckte Werte“). Warnungen dem Nutzer nennen und möglichst gleich +beheben. +``` + +- [ ] **Step 2: `docs/guides/divi-placeholders.md` – Tabellenzeile** + +Vorher (Zeile 16): + +```markdown +| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | +``` + +Nachher: + +```markdown +| `"preset": "<Name>"` oder `["<Name>", …]`, `"group_presets": {…}` | Modul-Preset bzw. Stapel und Gruppen-Presets (keine Platzhalter, sondern eigene Felder, siehe `divi-shortform`) | +``` + +- [ ] **Step 3: `docs/guides/divi-design-system.md` ergänzen** + +**3a. Löschen (Zeilen 42–45).** Vorher: + +```markdown +- `lit/divi-presets-delete` – nur Presets, die nirgends ausdrücklich verwendet werden (Seiten, Bibliothek, Theme + Builder, verschachtelt in anderen Presets); sonst `in_use` mit den Fundstellen. Ein Standard-Preset darf gelöscht + werden, auch wenn Module es ohne eigenes Preset nutzen: der Probelauf listet diese Seiten, danach zeigen sie das + Divi-Standardaussehen. +``` + +Nachher: + +```markdown +- `lit/divi-presets-delete` – nur Presets, die nirgends ausdrücklich verwendet werden (Seiten, Bibliothek, Theme + Builder, verschachtelt in anderen Presets); sonst `in_use` mit den Fundstellen und dem Hinweis auf + `lit/divi-design-detach`. Ein Standard-Preset darf gelöscht werden, auch wenn Module es ohne eigenes Preset + nutzen: der Probelauf listet diese Seiten, danach zeigen sie das Divi-Standardaussehen. Das gilt auch für + Standard-Gruppen-Presets: gelistet werden die Seiten mit Modulen, die diese Gruppe haben, aber kein eigenes + Gruppen-Preset dafür. +``` + +(Ein alter Hinweis „diese Seiten sind nicht aufgelistet“ steht in keinem Guide, nur im Code – dort entfernt ihn Task 6; `grep -rn "nicht aufgelistet" docs/guides` bleibt leer.) + +**3b. Neuer Abschnitt am Dateiende.** Nach dem Absatz „Rechte: Presets ändern …“ (letzte Zeile der Datei) anhängen: + +````markdown + +## Presets auf Seiten zuweisen und Verweise entfernen + +Einzelne Module bekommen Presets mit `lit-divi-insert`, `lit-divi-update` und `lit-content-create` (Stapel, +`group_presets`, `clear_overrides`: siehe `divi-shortform`). Für viele Seiten gibt es zwei Werkzeuge (über +`mcp-adapter-execute-ability`); beide ändern Seiteninhalt, nicht den Preset-Bestand. + +**`lit/divi-presets-assign`** – ein Preset oder einen Stapel (`preset`) und/oder Gruppen-Presets +(`group_presets`) allen Modulen eines Typs (`module`, z. B. `"button"`) zuweisen: + +- Filter: `post_ids` (nur diese Einträge; ohne die Angabe alle Fundstellen mit diesem Modultyp), + `only_without_preset: true` (nur Module ohne Preset) oder `only_with_preset: "<Name>"` (nur Module, die dieses + Preset schon tragen), `clear_overrides: true` (lokale Werte entfernen, die das Preset überdecken). +- Ablauf: erst ohne `confirm` aufrufen (Probelauf: `confirm_required` mit `pages` – je Seite `post_id`, `title`, + `status`, die betroffenen Knoten mit Pfad-ID, Modul und Label sowie `warnings` zu überdeckten Werten –, dazu + `pages_count`, `nodes_count`, `skipped` und gegebenenfalls `blocked_ids`). Dem Nutzer Seiten, Anzahl der Module + und Überdeckungen zeigen und **erst nach seiner Antwort** denselben Aufruf mit `confirm: true` senden. Ohne + `confirm` wird nie etwas geschrieben. +- Je Aufruf werden höchstens **50 Seiten** geschrieben. Die Antwort nennt `saved` (je Seite `post_id`, + `revision_id`, Anzahl Module) und `remaining`: Ist `remaining` größer als 0, denselben Aufruf wiederholen – bereits + umgestellte Module ändern sich nicht noch einmal. Jede Seite bekommt ihre eigene WordPress-Revision; einen + Snapshot gibt es für Seiteninhalte nicht. +- Darf das Konto eine veröffentlichte Fundstelle nicht bearbeiten (`edit_post`), steht sie in `blocked_ids` und es + wird **nichts** geschrieben. Scheitert das Speichern mitten im Lauf, meldet `partial_write` die bis dahin + gespeicherten Seiten (`saved`); die übrigen mit einem neuen Probelauf weiterführen. +- Globale Module und die Bibliothek: Eine globale Instanz (`[global <ID>]` in `lit-divi-outline`) wird auf der Seite + nicht umgeschrieben, sondern unter `skipped` mit Hinweis auf ihren Bibliothekseintrag gemeldet. Der + Bibliothekseintrag selbst ist eine eigene Fundstelle und wird wie eine Seite umgeschrieben (wirkt dann überall, + siehe `divi-library`). Seiten, die sich nicht ändern lassen (Divi 4, beschädigtes Markup), stehen mit dem Grund + unter `skipped`. +- Rechte wie bei den Presets (Recht, das Design anzupassen, und Visual Builder) plus `edit_post` für jede + Fundstelle; Theme-Builder-Layouts nach den Regeln des Theme Builders. + +**`lit/divi-design-detach`** – Verweise auf ein Preset überall entfernen, damit es sich löschen lässt: + +- Eingabe: `module` bzw. `group` plus `preset` (Name oder ID), optional `post_ids`. +- Ablauf wie bei jeder Änderung des Bestands: Probelauf (`confirm_required` mit `diff`, Seiten, `design_rev`), + dem Nutzer zeigen, dann `confirm: true` mit `design_rev` (veraltet: `rev_mismatch`, dann wird weder am Bestand + noch an Seiten etwas geschrieben). +- Zuerst werden verschachtelte Verweise in **anderen Presets** entfernt (mit Snapshot, wie `lit/divi-presets-set`), + danach die Seiten (höchstens 50 je Aufruf, `remaining` wie oben; ein weiterer Aufruf findet keine + verschachtelten Verweise mehr und macht mit den Seiten weiter). Mit `post_ids` werden nur diese Seiten + geändert. +- Die Module fallen auf das nächste Preset ihres Stapels zurück, sonst auf das Standard-Preset bzw. Divis + Standardaussehen. Ein **Standard-Preset** wird nicht „entkoppelt“, weil Divi es ohne Verweis anwendet; detach + entfernt dort nur ausdrückliche Verweise und sagt das in der Antwort. Zum Beenden der Standardwirkung das Preset + löschen (`lit/divi-presets-delete`) oder mit `make_default: false` den Standard aufheben. +- Variablen: `variable` wird noch nicht unterstützt (`unsupported`, kommt mit Etappe 4c). +- Danach `lit/divi-presets-delete` aufrufen (Probelauf, `confirm`, `design_rev`); es meldet kein `in_use` mehr, + solange niemand das Preset neu zugewiesen hat. +```` + +- [ ] **Step 4: `docs/guides/divi-structure.md` – Outline zeigt Gruppen-Presets** + +Vorher (Zeilen 9–11 und 15–16): + +```markdown +- `lit-divi-outline` mit `post_id` liefert jeden Knoten mit **Pfad-ID**, Modultyp, Admin-Label, Textauszug, + Layout-Info (z. B. `Spalten 8_24+16_24`) und Preset-Namen – als kompakten Text (`outline`) und als Liste + (`nodes`). Mit `id` nur ein Teilbaum, mit `depth` begrenzte Tiefe. +``` + +```markdown +- `lit-divi-node-get` mit `post_id` und `id` liefert alle Einstellungen eines Knotens (siehe `divi-attrs`), + die aktuellen Kurzform-Werte (`shortform`) und die IDs der Kinder. +``` + +Nachher: + +```markdown +- `lit-divi-outline` mit `post_id` liefert jeden Knoten mit **Pfad-ID**, Modultyp, Admin-Label, Textauszug, + Layout-Info (z. B. `Spalten 8_24+16_24`), Modul-Preset-Namen und Gruppen-Presets – als kompakten Text + (`outline`, z. B. `{Preset: CTA primär} {Gruppen-Presets: font: Button-Schrift; spacing: Abstand Karte}`) und + als Liste (`nodes`, dort `group_presets` mit Einträgen wie `"font: Button-Schrift"`). Mit `id` nur ein + Teilbaum, mit `depth` begrenzte Tiefe. +``` + +```markdown +- `lit-divi-node-get` mit `post_id` und `id` liefert alle Einstellungen eines Knotens (siehe `divi-attrs`), + die aktuellen Kurzform-Werte (`shortform`), die Gruppen-Presets (`group_presets`: je Gruppe `group_id`, + `group` und die Presets mit `id` und `name`) und die IDs der Kinder. +``` + +- [ ] **Step 5: `docs/guides/workflows.md` – Ablauf „Design auf Seiten ausrollen“** + +Einfügen direkt vor `## Text auf vielen Seiten ersetzen` (nach dem Absatz „Bibliothek und globale Module“). Vorher (Anker, bleibt): + +```markdown +## Text auf vielen Seiten ersetzen +``` + +Nachher (neuer Abschnitt plus Anker): + +````markdown +## Design auf Seiten ausrollen + +Ein Preset soll auf vielen Seiten gelten oder ein altes Preset verschwinden (Presets anlegen: `divi-design-system`): + +1. `lit-divi-design-system` lesen (Presets je Modul) und das Preset bei Bedarf mit `lit/divi-presets-set` anlegen + (Probelauf, nach Rückfrage `confirm: true` mit `design_rev`). +2. Einzelne Module: `lit-divi-update` mit `preset` (auch Stapel) und `group_presets`; `warnings` zu überdeckten + lokalen Werten dem Nutzer nennen, auf Wunsch `clear_overrides: true`. +3. Viele Seiten: `lit/divi-presets-assign` mit `module`, `preset`/`group_presets` und Filter (`post_ids`, + `only_without_preset`, `only_with_preset`) zuerst ohne `confirm`. Dem Nutzer die Seiten, die Zahl der Module und + die Überdeckungen zeigen; nach seiner Antwort denselben Aufruf mit `confirm: true`. Bei `remaining` größer als 0 + wiederholen (höchstens 50 Seiten je Aufruf). Jede Seite bekommt eine Revision. +4. Ein Preset aufgeben: `lit/divi-design-detach` (Probelauf, nach Rückfrage `confirm: true` mit `design_rev`; + bei `remaining` wiederholen), danach `lit/divi-presets-delete` (ebenfalls Probelauf und `confirm`). +5. Stichprobe: `lit-divi-outline` einer betroffenen Seite (zeigt Preset und Gruppen-Presets je Modul) und + `lit-page-render-summary`; dem Nutzer die Vorschau nennen. + +```` + +- [ ] **Step 6: Guide-Tests laufen lassen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit tests/Unit/Guides && grep -rn "lit_[a-z_]*\`" docs/guides | grep -v "lit_mcp" ; grep -rn "nicht aufgelistet" docs/guides` +Expected: PHPUnit grün (ToolNamingTest: `lit/divi-presets-assign` und `lit/divi-design-detach` sind indirekt, `lit-divi-update` u. a. direkt; `testReferencedGuideTopicsExist` und `testOverviewListsExactlyTheOtherGuideTopics` unverändert grün); beide greps ohne Ausgabe. Schlägt ToolNamingTest fehl, den genannten Namen im Guide korrigieren (nicht den Test). + +- [ ] **Step 7: Entscheidungsnotiz nachführen** + +In `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md`: + +**7a.** Die Zeile aus dem Abschnitt „Umsetzung 4a“ ersetzen. Vorher: + +```markdown +- Offen für 4b: Fundstellen von Standard-Gruppen-Presets (Gruppenliste je Modul), lit/divi-design-detach. +``` + +Nachher: + +```markdown +- Offen für 4b (erledigt in 4b, R11 und Task 10): Fundstellen von Standard-Gruppen-Presets (Gruppenliste je Modul), lit/divi-design-detach. +``` + +**7b.** Direkt nach dem Abschnitt „Umsetzung 4a“ (vor „## Offene Kleinpunkte aus 4a“) einfügen: + +````markdown +## Etappe 4b (Plan 2026-10-18-etappe-4b-zuweisen.md) +- Tools: lit-divi-insert/-update/lit-content-create (preset-Stapel, group_presets, clear_overrides), lit/divi-presets-assign, lit/divi-design-detach (beide indirekt); Outline und node-get zeigen Gruppen-Presets. +- R1 Fundstellen: keine Klasse DesignUsages; GlobalUsages::reportFor()/rowsFor() plus ModuleUsageQuery (alle Module eines Typs). +- R2 Stapel: preset = String | Liste (1–10) | null; doppelte IDs und leere Liste sind Fehler; letzter im Stapel gewinnt bei gleicher Priorität. +- R3 Gruppen-Schlüssel: Kurzname, voller Name oder Attributpfad; mehrdeutiger Name → Fehler mit den Pfaden; GroupKeys ersetzt die private Suche im PresetComposer. +- R4 Überdeckungen (G9): Vergleich je Blatt (Pfad, Breakpoint, Zustand), nur ausdrücklich zugewiesene Presets, Warnung nur bei abweichendem Wert; clear_overrides entfernt alle lokalen Blätter auf Preset-Blättern. +- R5 Warnungstext je Preset eine Zeile, bis zu 5 Pfade. +- R6 Outline: group_presets je Zeile und in node-get; Fingerprint unverändert. +- R7 clear_overrides beim Einfügen wirkt nach set/attrs desselben Knotens; nicht-boolesch → Fehler. +- R8 Seiten-Umschreiber: globale Instanzen nie umgeschrieben (skipped), Bibliothekseintrag ist eigene Fundstelle; nicht ladbare Seiten skipped; remaining zählt nur Seiten mit Änderungen. +- R9 Kein unfiltered_html-Zwang: der Umschreiber ändert nur Preset-Verweise und entfernt lokale Werte, Speichern über DiviSaver. +- R10 detach nur Presets: variable → unsupported (4c); verschachtelte Verweise über DesignChange (Snapshot), danach Seiten (≤ 50); Standard-Presets nur ausdrückliche Verweise. +- R11 Standard-Gruppen-Presets: PresetUsageMatcher erkennt implizite Nutzung (Modul hat die Gruppe, kein ausdrücklicher Eintrag); Kandidaten fail closed über wp:divi/; der 4a-Hinweis „nicht aufgelistet“ entfällt. +- Grenze je Aufruf A6: 50 Seiten, remaining; Teilerfolg partial_write mit den gespeicherten Seiten. +- Offen für 4c: detach für Variablen (A7: Referenz durch den aktuellen Wert ersetzen, bei Farben entfällt detach), Wiederverwendung des PageRewriter/TreeRewrite dafür (NodeRewrite-Strategie `VariableDetachRewrite`). +- Offen für 4c/4e: Smoke divi-presets-assign-smoke.sh läuft nur mit deaktivierten Plugins postlos und asynclistry. +```` + +- [ ] **Step 8: Smoke-Skript schreiben** + +`tests/Integration/divi-presets-assign-smoke.sh`: + +````bash +#!/usr/bin/env bash +# End-to-end checks of Etappe 4b (assign presets to modules, overrides, mass assignment, detach, delete) through the +# OAuth-protected endpoint against the real Divi. Runs INSIDE the DDEV web container: +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh +# Runs only with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of the WP-CLI calls). +# Creates presets named "LIT Smoke <run> …" and two draft pages with one button each; the EXIT trap restores the +# preset inventory taken before the first change and deletes the pages (with revisions) by exact ID. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +RUN="$(date +%s)-$$" +PREFIX="LIT Smoke $RUN" +ORIGINAL_PRESETS=$(wp eval 'echo wp_json_encode(ET\Builder\Packages\GlobalData\GlobalPreset::get_data());' 2>/dev/null) +PAGE1="" +PAGE2="" +NODE="s0.r0.c0.m0" +CONTENT='<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}}} --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Los"}}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->' + +cleanup() { + wp eval '$d = json_decode(getenv("LIT_PRESETS"), true); et_update_option(ET\Builder\Packages\GlobalData\GlobalPreset::option_name(), $d, false, "", "", true);' \ + --user="$ADMIN_LOGIN" >/dev/null 2>&1 || echo "WARN: preset inventory not restored" + for id in $PAGE1 $PAGE2; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + # Change-log rows about the test pages. + LIT_IDS="$PAGE1 $PAGE2" wp eval ' +global $wpdb; +$table = $wpdb->prefix . "lit_mcp_log"; +foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) { + $wpdb->delete($table, ["object_type" => "page", "object_id" => $id]); +}' >/dev/null 2>&1 || true + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +export LIT_PRESETS="$ORIGINAL_PRESETS" +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +connect "$ADMIN_LOGIN" + +create_page() { # $1 = title -> prints the ID of a draft Divi 5 page with one button without preset + LIT_TITLE="$1" LIT_CONTENT="$CONTENT" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' 2>/dev/null +} + +button_attrs() { # $1 = post ID -> attributes (JSON) of the first divi/button block as stored + LIT_ID="$1" wp eval ' +$find = static function (array $blocks) use (&$find) { + foreach ($blocks as $b) { + if ($b["blockName"] === "divi/button") { return $b["attrs"]; } + $r = $find($b["innerBlocks"]); + if ($r !== null) { return $r; } + } + return null; +}; +echo wp_json_encode($find(parse_blocks(get_post((int) getenv("LIT_ID"))->post_content)));' 2>/dev/null +} + +revisions_of() { # $1 = post ID -> number of revisions + LIT_ID="$1" wp eval 'echo count(wp_get_post_revisions((int) getenv("LIT_ID")));' 2>/dev/null +} + +preset_stored() { # $1 = module|group, $2 = divi/<name>, $3 = preset ID -> exit 0 when Divi stores it + LIT_KIND="$1" LIT_SCOPE="$2" LIT_PID="$3" wp eval ' +$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); +exit(isset($d[getenv("LIT_KIND")][getenv("LIT_SCOPE")]["items"][getenv("LIT_PID")]) ? 0 : 1);' 2>/dev/null +} + +nested_groups() { # $1 = module preset ID -> groupPresets (JSON) of that button preset + LIT_PID="$1" wp eval ' +$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); +echo wp_json_encode($d["module"]["divi/button"]["items"][getenv("LIT_PID")]["groupPresets"] ?? new stdClass());' 2>/dev/null +} + +details() { # $1 = tool()/ability() result -> details object of an error, or the result itself + echo "$1" | jq -c 'if has("__error") then ((.__error | fromjson?) // {}) else . end' +} + +# Runs lit/divi-presets-set with dry run and confirm; sets RESULT. $1 = input JSON without confirm. +presets_set() { + local dry rev + dry=$(ability lit/divi-presets-set "$1") + [ "$(error_code "$dry")" = "confirm_required" ] || fail "presets-set dry run: $dry" + rev=$(echo "$dry" | jq -r '.__error | fromjson | .design_rev') + RESULT=$(ability lit/divi-presets-set "$(echo "$1" | jq -c --arg r "$rev" '. + {confirm: true, design_rev: $r}')") + [ "$(echo "$RESULT" | jq -r '.ok')" = "true" ] || fail "presets-set confirm: $RESULT" +} + +preset_id() { # $1 = module|group, $2 = short name ("button", "font"), $3 = preset name -> prints the ID + local id + id=$(ability lit/divi-preset-get "$(jq -cn --arg k "$1" --arg s "$2" --arg p "$3" '{($k): $s, preset: $p}')" | jq -r '.id // empty') + [ -n "$id" ] || fail "preset $3 not found" + echo "$id" +} + +tree_rev() { # $1 = post ID + tool lit-divi-outline "{\"post_id\":$1}" | jq -r '.tree_rev' +} + +# 1. Two draft pages with one button without preset each. +PAGE1=$(create_page "$PREFIX Seite 1") +PAGE2=$(create_page "$PREFIX Seite 2") +[ -n "$PAGE1" ] && [ -n "$PAGE2" ] || fail "pages not created" +for id in "$PAGE1" "$PAGE2"; do + button_attrs "$id" | jq -e '(.modulePreset // null) == null and (.groupPreset // null) == null' >/dev/null \ + || fail "page $id starts with a preset" +done +pass "two draft pages with a button without preset ($PAGE1, $PAGE2)" + +# 2. Presets: two button module presets (A, B) and a font group preset (G); B nests G. +presets_set "$(jq -cn --arg a "$PREFIX A" --arg b "$PREFIX B" --arg g "$PREFIX G" '{items: [ + {module: "button", name: $a, attrs: {"button.decoration.background": {desktop: {color: "#123456"}}}}, + {module: "button", name: $b, attrs: {"button.decoration.background": {desktop: {color: "#654321"}}}}, + {group: "font", name: $g, source_module: "button", + attrs: {"button.decoration.font": {font: {desktop: {weight: "700"}}}}}]}')" +PRESET_A=$(preset_id module button "$PREFIX A") +PRESET_B=$(preset_id module button "$PREFIX B") +PRESET_G=$(preset_id group font "$PREFIX G") +presets_set "$(jq -cn --arg b "$PREFIX B" --arg g "$PREFIX G" '{items: [ + {module: "button", preset: $b, group_presets: {font: $g}}]}')" +nested_groups "$PRESET_B" | jq -e --arg g "$PRESET_G" '."button.decoration.font".presetId == [$g]' >/dev/null \ + || fail "preset B does not nest group preset G" +pass "module presets A, B and group preset G created, B nests G" + +# 3. A stack and a group preset on page 1 through lit-divi-update. +TREE=$(tree_rev "$PAGE1") +UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE1" --arg rev "$TREE" --arg n "$NODE" --arg a "$PREFIX A" \ + --arg b "$PREFIX B" --arg g "$PREFIX G" '{post_id: $id, tree_rev: $rev, changes: [ + {id: $n, preset: [$a, $b], group_presets: {font: $g}}]}')") +echo "$UPDATE" | jq -e '.ok == true and (.tree_rev | length) > 0 and ((.warnings // []) | map(select(contains("überdecken"))) | length) == 0' \ + >/dev/null || fail "update with stack and group preset: $UPDATE" +button_attrs "$PAGE1" | jq -e --arg a "$PRESET_A" --arg b "$PRESET_B" --arg g "$PRESET_G" \ + '.modulePreset == [$a, $b] and .groupPreset == {"button.decoration.font": {presetId: [$g], groupName: "divi/font"}}' >/dev/null \ + || fail "stored modulePreset/groupPreset of page 1: $(button_attrs "$PAGE1")" +OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE1}") +echo "$OUTLINE" | jq -e --arg n "$NODE" --arg g "font: $PREFIX G" \ + 'any(.nodes[]; .id == $n and ((.group_presets // []) | index($g) != null)) and (.outline | contains("Gruppen-Presets: " + $g))' \ + >/dev/null || fail "outline lacks the group preset: $OUTLINE" +pass "stack [A, B] and group preset stored; outline shows the group preset" + +# 4. Overrides: a differing local value is reported, clear_overrides removes it; a non-boolean value is refused. +TREE=$(tree_rev "$PAGE2") +UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE2" --arg rev "$TREE" --arg n "$NODE" --arg a "$PREFIX A" \ + '{post_id: $id, tree_rev: $rev, changes: [ + {id: $n, preset: $a, attrs: {"button.decoration.background": {desktop: {color: "#abcdef"}}}}]}')") +echo "$UPDATE" | jq -e --arg n "$NODE" --arg a "$PREFIX A" \ + '.ok == true and ((.warnings // []) | map(select(contains($n) and contains("überdecken") and contains($a) and contains("clear_overrides"))) | length) == 1' \ + >/dev/null || fail "no override warning: $UPDATE" +button_attrs "$PAGE2" | jq -e --arg a "$PRESET_A" '.modulePreset == [$a] and .button.decoration.background != null' >/dev/null \ + || fail "page 2 should keep its local background: $(button_attrs "$PAGE2")" +TREE=$(echo "$UPDATE" | jq -r '.tree_rev') +BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE2" --arg rev "$TREE" --arg n "$NODE" \ + '{post_id: $id, tree_rev: $rev, changes: [{id: $n, clear_overrides: "ja"}]}')") +[ "$(error_code "$BAD")" = "validation_failed" ] || fail "non-boolean clear_overrides: $BAD" +CLEARED=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE2" --arg rev "$TREE" --arg n "$NODE" --arg a "$PREFIX A" \ + '{post_id: $id, tree_rev: $rev, changes: [{id: $n, preset: $a, clear_overrides: true}]}')") +echo "$CLEARED" | jq -e '.ok == true and ((.warnings // []) | map(select(contains("überdecken"))) | length) == 0' >/dev/null \ + || fail "clear_overrides: $CLEARED" +button_attrs "$PAGE2" | jq -e --arg a "$PRESET_A" '.modulePreset == [$a] and (.button.decoration.background // null) == null + and .button.innerContent.desktop.value.text == "Los"' >/dev/null \ + || fail "clear_overrides left or removed the wrong values: $(button_attrs "$PAGE2")" +pass "override warning, clear_overrides removes exactly the covering value, non-boolean refused" + +# 5. Mass assignment over both pages: stack [B] and group preset G. +ASSIGN=$(jq -cn --argjson p1 "$PAGE1" --argjson p2 "$PAGE2" --arg b "$PREFIX B" --arg g "$PREFIX G" \ + '{module: "button", preset: $b, group_presets: {font: $g}, post_ids: [$p1, $p2]}') +NONE=$(ability lit/divi-presets-assign "$(echo "$ASSIGN" | jq -c '. + {only_without_preset: true}')") +details "$NONE" | jq -e '(.pages_count // 0) == 0' >/dev/null || fail "only_without_preset should match no page: $NONE" +REVISIONS_BEFORE=$(revisions_of "$PAGE1") +DRY=$(ability lit/divi-presets-assign "$ASSIGN") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "assign without confirm: $DRY" +details "$DRY" | jq -e --argjson p1 "$PAGE1" --argjson p2 "$PAGE2" \ + '.pages_count == 2 and .nodes_count == 2 and ([.pages[].post_id] | sort) == ([$p1, $p2] | sort) + and ((.blocked_ids // []) | length) == 0' >/dev/null || fail "assign dry run: $DRY" +button_attrs "$PAGE1" | jq -e --arg a "$PRESET_A" --arg b "$PRESET_B" '.modulePreset == [$a, $b]' >/dev/null \ + || fail "dry run changed page 1" +[ "$(revisions_of "$PAGE1")" = "$REVISIONS_BEFORE" ] || fail "dry run created a revision" +ASSIGNED=$(ability lit/divi-presets-assign "$(echo "$ASSIGN" | jq -c '. + {confirm: true}')") +echo "$ASSIGNED" | jq -e --argjson p1 "$PAGE1" --argjson p2 "$PAGE2" \ + '.ok == true and (.saved | length) == 2 and .remaining == 0 and ([.saved[].post_id] | sort) == ([$p1, $p2] | sort)' \ + >/dev/null || fail "assign with confirm: $ASSIGNED" +for id in "$PAGE1" "$PAGE2"; do + button_attrs "$id" | jq -e --arg b "$PRESET_B" --arg g "$PRESET_G" \ + '.modulePreset == [$b] and .groupPreset == {"button.decoration.font": {presetId: [$g], groupName: "divi/font"}}' \ + >/dev/null || fail "page $id after mass assignment: $(button_attrs "$id")" +done +REVISIONS_AFTER=$(revisions_of "$PAGE1") +AGAIN=$(ability lit/divi-presets-assign "$ASSIGN") +[ "$(error_code "$AGAIN")" != "confirm_required" ] || fail "second dry run still finds changes: $AGAIN" +details "$AGAIN" | jq -e '(.pages_count // 0) == 0' >/dev/null || fail "second dry run lists pages: $AGAIN" +[ "$(revisions_of "$PAGE1")" = "$REVISIONS_AFTER" ] || fail "second dry run created a revision" +pass "mass assignment: dry run lists 2 pages, confirm saved 2 (remaining 0), second dry run finds nothing" + +# 6. detach of the group preset: it is in use, a stale design_rev is refused, then dry run, confirm, delete. +GROUP_ITEM=$(jq -cn --arg g "$PRESET_G" '{items: [{group: "font", preset: $g}]}') +[ "$(error_code "$(ability lit/divi-presets-delete "$GROUP_ITEM")")" = "in_use" ] || fail "delete of a used group preset" +UNSUPPORTED=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}') +[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED" +DETACH=$(jq -cn --arg g "$PRESET_G" '{preset: {group: "font", preset: $g}}') +DRY=$(ability lit/divi-design-detach "$DETACH") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "detach without confirm: $DRY" +DETACH_REV=$(details "$DRY" | jq -r '.design_rev') +details "$DRY" | jq -e '.pages_count == 2 and (.diff | length) > 0' >/dev/null || fail "detach dry run: $DRY" +STALE=$(ability lit/divi-design-detach "$(echo "$DETACH" | jq -c '. + {confirm: true, design_rev: "veraltet"}')") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" +nested_groups "$PRESET_B" | jq -e 'length > 0' >/dev/null || fail "stale detach changed preset B" +button_attrs "$PAGE1" | jq -e '(.groupPreset // null) != null' >/dev/null || fail "stale detach changed page 1" +pass "group preset is in use, variable detach unsupported, stale design_rev refused without writing" + +DETACHED=$(ability lit/divi-design-detach "$(echo "$DETACH" | jq -c --arg r "$DETACH_REV" '. + {confirm: true, design_rev: $r}')") +echo "$DETACHED" | jq -e '.ok == true and (.saved | length) == 2 and .remaining == 0' >/dev/null \ + || fail "detach with confirm: $DETACHED" +nested_groups "$PRESET_B" | jq -e 'length == 0' >/dev/null || fail "preset B still nests the group preset" +for id in "$PAGE1" "$PAGE2"; do + button_attrs "$id" | jq -e --arg b "$PRESET_B" '.modulePreset == [$b] and (.groupPreset // null) == null' >/dev/null \ + || fail "page $id after detach: $(button_attrs "$id")" +done +pass "detach removed the nested reference (snapshot pipeline) and the page references" + +DRY=$(ability lit/divi-presets-delete "$GROUP_ITEM") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "delete dry run after detach: $DRY" +DELETE_REV=$(details "$DRY" | jq -r '.design_rev') +DELETED=$(ability lit/divi-presets-delete "$(echo "$GROUP_ITEM" | jq -c --arg r "$DELETE_REV" '. + {confirm: true, design_rev: $r}')") +echo "$DELETED" | jq -e '.ok == true' >/dev/null || fail "delete after detach: $DELETED" +if preset_stored group divi/font "$PRESET_G"; then fail "group preset $PRESET_G is still stored"; fi +pass "group preset deletable after detach" + +finish +```` + +Das Skript prüft die Antwortformen so: `lit-divi-update` liefert `ok`, `tree_rev`, `warnings`, `revision_id` (vorhandene `DiviWriter`-Antwort); `lit/divi-presets-set`/`-delete` und der Probelauf von `lit/divi-design-detach` die Pipeline-Formen von `DesignChange` (`confirm_required` mit `diff`, `design_rev`, `warnings`, `affected`; Erfolg `ok`, `snapshot_id`, `design_rev`); `lit/divi-presets-assign` und `lit/divi-design-detach` zusätzlich die `PageRewriter`-Formen (Probelauf: `pages[]` mit `post_id`/`nodes`, `pages_count`, `nodes_count`, `skipped`, `blocked_ids`; Erfolg: `saved[]` mit `post_id`/`revision_id`/`nodes`, `remaining`, `warnings`, `skipped`). Weichen die ausgelieferten Tasks 9/10 davon ab (andere Schlüssel, Eingabeform von `lit/divi-design-detach`), das Skript an die Ability anpassen – nicht die Ability an das Skript. + +- [ ] **Step 9: Syntax prüfen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && bash -n tests/Integration/divi-presets-assign-smoke.sh && chmod +x tests/Integration/divi-presets-assign-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x -e SC1091 tests/Integration/divi-presets-assign-smoke.sh || true)` +Expected: keine Ausgabe von `bash -n`; shellcheck (falls vorhanden) ohne Fehler. + +- [ ] **Step 10: Smoke laufen lassen (Plugins nur bei Bedarf deaktivieren)** + +Das Skript läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`. Die Plugins nur deaktivieren, wenn sie aktiv waren, und danach wieder aktivieren (auch nach einem Fehlschlag). Der Lauf dauert bis zu 15 Minuten, deshalb mit `run_in_background` starten: + +```bash +bash -c ' +cd /home/janniclas/Projekte/WordPress +was="" +for p in postlos asynclistry; do + if ddev wp plugin is-active "$p" >/dev/null 2>&1; then was="$was $p"; ddev wp plugin deactivate "$p"; fi +done +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh; rc=$? +for p in $was; do ddev wp plugin activate "$p"; done +exit $rc +' +``` + +Expected: acht `ok - …`-Zeilen (Seiten, Presets, Stapel und Gruppe, Überdeckung, Massenzuweisung, Stale-Rückweisung, detach, Löschen) und `ALL CHECKS PASSED`; Exit-Code 0; `ddev wp plugin list --status=active --field=name` zeigt danach `postlos` und `asynclistry` wieder, wenn sie vorher aktiv waren. + +Danach prüfen, dass nichts übrig blieb: +`ddev wp post list --post_type=page --post_status=draft --s="LIT Smoke" --field=ID` (leer) und +`ddev wp eval 'echo count(ET\Builder\Packages\GlobalData\GlobalPreset::get_data()["module"]["divi/button"]["items"] ?? []);'` (gleiche Zahl wie vor dem Lauf). + +Scheitert ein Schritt: erst klären, ob Skript oder Ability falsch liegt (Antwort der Ability im `FAIL`-Text lesen). Mögliche Abweichungen: Schreibweise der Divi-Werte (`weight: "700"`), Name des Hinweises in `warnings` (Task 2 `describe`), Eingabeform von `lit/divi-design-detach`, Form der Leer-Antwort des zweiten Probelaufs. Nicht die Prüfung lockern, sondern die Ursache beheben (bei einer Ability-Abweichung als Fix-Commit im jeweiligen Task-Bereich). + +- [ ] **Step 11: Gesamtlauf, Lint, Commits** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests grün (Stand 4a: 2454 plus die neuen Tests aus Tasks 1–10), phpcs ohne Meldung. Die Smokes `divi-presets-smoke.sh`, `divi-write-smoke.sh`, `divi-library-smoke.sh`, `tb-smoke.sh` und `divi-release-smoke.sh` einmal mit denselben Plugin-Schritten wie oben nacheinander laufen lassen (Regression der Schreib-Tools, die Task 4 geändert hat); alle enden mit `ALL CHECKS PASSED`. + +Zwei Commits, nur die genannten Dateien stagen, Commit wie in den Global Constraints: + +```bash +git add docs/guides/divi-shortform.md docs/guides/divi-placeholders.md docs/guides/divi-design-system.md docs/guides/divi-structure.md docs/guides/workflows.md +git commit -m "docs: Guides zu Preset-Stapeln, Gruppen-Presets, Überdeckungen, Massenzuweisung und detach" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" + +git add tests/Integration/divi-presets-assign-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md +git commit -m "test: Ende-zu-Ende-Test der Zuweisung (Stapel, Gruppen, Überdeckung, Massenzuweisung, detach); Entscheidungsnotiz zu 4b" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" +``` + +Memory- und Stand-Notizen (`MEMORY.md`, `etappe-4-stand.md`) fasst dieser Task nicht an; das macht der Koordinator. + +--- + +## Abschluss von 4b + +- [ ] `composer test` und `composer lint` grün; alle bisherigen Smokes (`divi-write-smoke.sh`, `divi-library-smoke.sh`, `tb-smoke.sh`, `divi-release-smoke.sh`, `divi-presets-smoke.sh`) und `divi-presets-assign-smoke.sh` grün. +- [ ] `DiviBoundaryTest` grün ohne Manifest-Änderung (4b braucht keinen neuen Divi-Aufruf). +- [ ] Review nach `superpowers:requesting-code-review` mit dem Review Focus oben; Clean-Code-Review (`anthropic-skills:clean-code-review`) über die neuen Klassen in `Divi/Design/`. +- [ ] Zuletzt: **Plan 4c schreiben** (Farben und Variablen: `lit/divi-colors-set`, `lit/divi-variables-set`, `detach` für Variablen über den vorhandenen `PageRewriter` mit einer `NodeRewrite`-Strategie für Variablen-Verweise). + +## Offene Punkte (Task 11) + +- Antwortformen von `lit/divi-presets-assign` und `lit/divi-design-detach` stehen beim Schreiben von Task 11 nur im Gerüst (Signaturen `PageRewriter::preview/apply`, `DesignChange`), die Tasks 9/10 waren noch nicht geschrieben. Das Skript nimmt an: Probelauf-Details `pages`, `pages_count`, `nodes_count`, `skipped`, `blocked_ids` (bei detach zusätzlich `diff`, `design_rev` aus `DesignChange`); Erfolg `ok`, `saved`, `remaining`. Abweichungen dort müssen im Skript und im Guide (`divi-design-system`, Abschnitt „Presets auf Seiten zuweisen …“) nachgezogen werden. +- Eingabeform von `lit/divi-design-detach`: Skript und Guide nehmen `module`/`group` + `preset` (Name oder ID) wie bei `lit/divi-presets-delete`, `variable` als Alternative und optional `post_ids`. Spec §6.8 nennt nur „`preset` (`module`/`group` + Name/ID)“; Task 10 muss dieselbe flache Form festlegen. +- Dry-Run-Verhalten von `lit/divi-design-detach` ohne verschachtelte Verweise: Das Skript nimmt an, dass der Probelauf immer `confirm_required` mit `design_rev` liefert (auch bei leerem Diff, solange Seiten betroffen sind). Entscheidet Task 10 anders (`ok` „Keine Änderung“ bei leerem Preset-Diff), muss die `design_rev`-Abfrage im Smoke angepasst werden; im Smoke ist der Diff durch die Verschachtelung B → G nicht leer. +- Leere Probeläufe von `lit/divi-presets-assign` (nichts zu ändern): Das Skript akzeptiert `ok` oder ein Detailobjekt mit `pages_count` 0 bzw. ohne `pages_count`, aber kein `confirm_required`. Task 9 sollte das eindeutig machen. +- Exakter Pfadtext der Überdeckungswarnung (R5, `PresetOverrides::describe`) im Guide ist beispielhaft (`button.decoration.background`); der Smoke prüft nur Knoten-ID, „überdecken“, Preset-Name und „clear_overrides“. Beispiel im Guide nach Task 2 mit der echten Ausgabe abgleichen. +- Divi-Werte in den Smoke-Presets (`weight: "700"` unter `button.decoration.font.font`, Gruppe `font` des Buttons mit genau einem Pfad `button.decoration.font`) wurden gegen die echte Gruppenliste von `divi/button` geprüft (nur Lesezugriff), die Speicherung durch Divi aber nicht ausgeführt; beim ersten Lauf bestätigen. +- `clear_overrides` allein in `lit-divi-update` wird im Smoke zusammen mit `preset` gesendet (R7 nennt es „wirkt nach set/attrs“); ob ein Eintrag mit nur `id` + `clear_overrides` akzeptiert wird, entscheidet Task 4. +- Das Skript löscht Protokollzeilen nur für die Testseiten (`object_type` `page`); Zeilen und Snapshots der Preset-Änderungen (`divi_design`, Option `lit_mcp_snapshots_presets`) bleiben wie bei `divi-presets-smoke.sh` stehen (bekannter Kleinpunkt aus 4a). +- Der Guide-Hinweis „alter Hinweis zu Standard-Gruppen-Presets“ existiert in keinem Guide, nur als `GROUP_DEFAULT_WARNING` in `DiviPresetsSetAbility` und `DETACH_HINT` („ab Etappe 4b“) in `DiviPresetsDeleteAbility`; beide räumen Task 6 bzw. das Gerüst auf, Task 11 ergänzt im Guide nur die Aussage, dass Standard-Gruppen-Presets gelistet werden. +