diff --git a/docs/superpowers/plans/2026-10-16-etappe-4a-presets.md b/docs/superpowers/plans/2026-10-16-etappe-4a-presets.md new file mode 100644 index 0000000..f87a3f4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-etappe-4a-presets.md @@ -0,0 +1,5394 @@ +# Etappe 4a – Designsystem: Änderungs-Pipeline, Snapshots und Presets – 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 liest Presets mit ihren Einstellungen (`lit/divi-preset-get`), legt Modul- und Gruppen-Presets an und ändert sie (`lit/divi-presets-set`, auch aus einem Modul einer Seite und als Standard-Preset), löscht unbenutzte Presets (`lit/divi-presets-delete`) und stellt den Preset-Bestand aus Snapshots wieder her (`lit/divi-design-snapshots`, `lit/divi-design-restore`) – jede Änderung mit Probelauf, `confirm`, Snapshot, Protokoll und über Divis eigenen Speicherweg. + +**Architecture:** Reines PHP in `Divi/Design/` beschreibt Bestand (`PresetBook`), Bereich (`PresetScope`), Unterschied (`PresetDiff`) und den Aufbau eines Presets (`PresetComposer`, nutzt den vorhandenen `Composer::update()` an einem gedachten Modulblock). Ein neuer Adapter `DesignSystemWriter` ist die einzige Divi-Berührung beim Schreiben; er wiederholt die Schrittfolge von Divis `GlobalPresetController::sync` (Mirror mit Fingerabdruck). Die gemeinsame `DesignChange`-Pipeline (Rechte geprüft vom Tool → Probelauf mit `diff`/`affected`/`design_rev` → `confirm` → `design_rev`-Vergleich → Snapshot (`SnapshotStore`) → Schreiben → Zurücklesen → Protokoll, bei Fehler Rollback) ist für alle Bereiche gebaut und wird hier mit dem Bereich `presets` (`PresetArea`) genutzt. Fundstellen liefert `GlobalUsages` über eine austauschbare Suche (`UsageQuery`): die bisherige Bibliothekssuche wird `LibraryUsageQuery`, neu kommt `PresetUsageQuery`. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, Optionen, `$wpdb`), Divi 5.15.1 (`GlobalPreset`, `Conversion`, `UserRole`, `ET_Core_PageResource`), `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` (§3–§6.4, §10–§14; Annahmen A1–A11 freigegeben), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G1–G12, verbindlich), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Stand 5.2.1, Task 1 führt sie auf 5.15.1 nach), Fixtures `tests/Fixtures/divi/stage4/`, Seed `tests/Integration/divi-seed-stage4-design.php`. + +**Voraussetzung:** Branch `etappe-4` mit Stand d391cb5 (Spec committet). Divi 5.15.1 ist installiert; die offene Freigabe von 5.15.1 (`DiviReleases::TESTED`, `fingerprints.json`, `vb-sync-flags.txt` im Arbeitsbaum) ist **vor Task 1 vom Benutzer committet**. 2345 Unit-Tests grün. + +**Teil von Etappe 4:** 4a (dieser Plan) → 4b Zuweisen an Seiten (Stapel, Gruppen, Überdeckungen, Massenzuweisung, detach) → 4c Farben und 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 stellen den Preset-Bestand am Ende aus dem eigenen Snapshot wieder her. +- 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 (`GlobalPreset`, `Conversion`, `UserRole`, `et_*`, `ET_*`) nur in Adaptern, die in `DiviAdapters::CLASSES` stehen und jeden Aufruf im Manifest führen; nachgebaute Divi-Abläufe als `Mirror` mit Fingerabdruck in `src/Integrations/Divi/Release/fingerprints.json` und Referenz in `tests/Fixtures/divi/reference/.txt`. `DiviBoundaryTest` bleibt grün. Jede schreibende Adapter-Methode ruft zuerst `ReleaseGate::assertWritable()`. +- `src/Integrations/Divi/BlockTree/*`, `src/Integrations/Divi/Compose/*` und die neuen reinen Klassen `PresetScope`, `PresetBook`, `PresetDiff`, `PresetComposer`, `PresetUsageMatcher` bleiben reines PHP ohne WordPress-Aufrufe. +- **Eine Engstelle für Client-Werte (D7):** Preset-Attribute aus `set`/`attrs` laufen ausschließlich durch `Composer::update()` (ShortformWriter/AttrWriter/ValueCleaner); Werte aus `from_node` sind gespeicherte Werte einer Seite und laufen durch `StoredNodeGuard` (Sperren S7/S26 für Benutzer ohne `unfiltered_html`). Vor dem Speichern bereinigt zusätzlich Divis `GlobalPreset::prepare_data()`. +- **Site-weite Änderungen (G2, Spec §4):** Ohne `confirm: true` nie schreiben; Probelauf = `Errors::confirmRequired()` mit `diff`, `affected`, `affected_count`, `design_rev`. Mit `confirm` Pflicht-Eingabe `design_rev`; Abweichung → `Errors::revMismatch()` mit eigener Meldung. Vor jedem Schreiben ein Snapshot; ohne Snapshot kein Schreiben. +- **Rechte (Spec §10):** Lesen = `edit_posts`. Presets schreiben, löschen, wiederherstellen = `edit_theme_options` + Divi `UserRole::can_current_user_use_visual_builder()` (über `DesignSystemWriter::canUseVisualBuilder()`). Snapshots auflisten = `edit_theme_options`. +- **Exposition:** Alle neuen Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe „Divi“ (`divi`). `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); 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`. Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- Commits: `git commit -m "" -m "Co-Authored-By: Claude Opus 5.5 "` (im Folgenden „Commit wie in den Global Constraints“). Nur die Dateien des Tasks stagen. + +## Review Focus + +1. **Preset mit Skript- oder HTML-Attributen von einem Redakteur ohne `unfiltered_html`** (über `set`/`attrs` oder `from_node` einer Admin-Seite): abgelehnt, nichts gespeichert → `PresetComposerTest::testEditorCannotCarryBuilderOnlyValuesFromANode`, `PresetsSetAbilityTest::testEditorWithoutUnfilteredHtmlIsRefusedForRawCss` (Tasks 9, 11). +2. **Inhalte im Preset** (Text, URL, Bild-ID): abgelehnt, weil Divis Preset-Mapping sie nicht als Preset-Attribut führt → `PresetComposerTest::testContentPathsAreRefused` (Task 9). +3. **Parallele Änderung** zwischen Probelauf und `confirm` (anderes Fenster im Builder): `rev_mismatch`, nichts geschrieben, kein Snapshot → `DesignChangeTest::testStaleDesignRevWritesNothing` (Task 8). +4. **Schreiben scheitert oder liest anders zurück**: Snapshot wird zurückgeschrieben, `write_failed`; scheitert auch das, `partial_write` mit `snapshot_id` → `DesignChangeTest::testFailedWriteRestoresTheSnapshot`, `::testFailedRollbackIsPartial` (Task 8). +5. **Löschen eines benutzten Presets** (Seite, Bibliothek, Theme Builder, als verschachteltes Gruppen-Preset): `in_use` mit Fundstellen → `PresetsDeleteAbilityTest::testPresetInUseIsRefused`, `::testNestedUseInAnotherPresetCounts` (Task 12). +6. **Rechte**: Autor ohne `edit_theme_options`, Administrator mit Divi-Rolle ohne Visual Builder → `forbidden` → `PresetsSetAbilityTest::testNeedsEditThemeOptionsAndTheVisualBuilder` (Task 11). +7. **Divi nicht freigegeben**: Schreiben gesperrt (`DiviLocked`) vor jedem Snapshot → `DesignSystemWriterTest::testWritesNeedAReleasedDivi` (Task 5), `DesignChangeTest::testLockedDiviWritesNoSnapshot` (Task 8). + +--- + +## File Structure + +``` +src/ + Uninstaller.php (geändert) Snapshot-Optionen + Integrations/Divi/ + DiviIntegration.php (geändert) neue Tools, Writer, Snapshots, Usages + Release/DiviAdapters.php (geändert) DesignSystemWriter + Release/fingerprints.json (geändert) preset-sync + Library/DiviRoles.php (unverändert) + Library/GlobalUsages.php (geändert) report()/rows() über UsageQuery + Library/UsageQuery.php (neu) Suchstrategie: Kandidaten-SQL + Treffer + Library/LibraryUsageQuery.php (neu) bisherige Bibliothekssuche + Design/ + PresetScope.php (neu) module|group + voller Name + PresetBook.php (neu) Preset-Bestand im Speicherformat + PresetDiff.php (neu) Unterschied zweier Bestände + PresetComposer.php (neu) Preset-Eintrag aus set/attrs/from_node + PresetUsageMatcher.php (neu) Inhalt verwendet Preset? (rein) + PresetUsageQuery.php (neu) UsageQuery für ein Preset + DesignSystemWriter.php (neu) Adapter: Presets schreiben, Mapping, Rechte + SnapshotStore.php (neu) Snapshots je Bereich + SnapshotTooLarge.php (neu) Ausnahme + DesignArea.php (neu) Schnittstelle eines Bereichs + PresetArea.php (neu) Bereich presets + DesignChange.php (neu) Pipeline + DesignPlan.php (neu) Ergebnis der Planung (neuer Bestand, diff, affected) + DiviPresetGetAbility.php (neu) lit/divi-preset-get + DiviPresetsSetAbility.php (neu) lit/divi-presets-set + DiviPresetsDeleteAbility.php (neu) lit/divi-presets-delete + DiviDesignSnapshotsAbility.php (neu) lit/divi-design-snapshots + DiviDesignRestoreAbility.php (neu) lit/divi-design-restore +tests/ + Fixtures/divi/reference/preset-sync.txt (neu) Referenz des Mirrors + Unit/Integrations/Divi/Design/…Test.php (neu) je Klasse + Unit/Integrations/Divi/Library/GlobalUsagesTest.php (geändert) Suche über UsageQuery + Unit/UninstallerTest.php (geändert) + Integration/divi-presets-smoke.sh (neu) Ende-zu-Ende gegen echtes Divi +docs/ + guides/divi-design-system.md (geändert) Abschnitt „Presets anlegen und ändern“ + superpowers/notes/2026-09-28-etappe-4-divi-formate.md (geändert) Stand 5.15.1 +``` + +--- + +### Task 1: Datenformate und Signaturen gegen Divi 5.15.1 prüfen (Presets, Rechte, Caches) + +Reiner Recherche-Task; Ergebnis ist die nachgeführte Formate-Notiz. Kein Produktionscode. + +**Files:** +- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` + +**Interfaces:** +- Consumes: — +- Produces: verifizierte Fakten, auf die Tasks 2–14 bauen (Abschnitt „Nachtrag 5.15.1“). + +- [ ] **Step 1: Divi-Version und Preset-Funktionen feststellen** + +Run: +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/themes/Divi +grep -m1 "Version:" style.css +cd includes/builder-5/server +grep -n "public static function \(option_name\|get_data\|prepare_data\|validate_preset_count\|save_data\|get_deleted_preset_ids\|remove_preset_from_legacy_option\)" Packages/GlobalData/GlobalPreset.php +grep -n "public static function \(get_preset_attrs\|get_preset_attrs_mapping\)" Packages/Conversion/Conversion.php +grep -n "public static function can_current_user_use_visual_builder" Framework/UserRole/UserRole.php +grep -n "public static function sync\|sync_permission\|edit_theme_options" Packages/GlobalData/GlobalPresetController.php +``` +Expected (Stand 5.15.1, beim Schreiben dieses Plans geprüft): `Version: 5.15.1`; `prepare_data( array $schema_items ): array`, `validate_preset_count( array $current_presets, array $presets_to_sync, string $action_type ): ?WP_Error`, `save_data( array $data, bool $skip_external_cache = false ): array`, `get_deleted_preset_ids( array $current_presets, array $incoming_presets, string $preset_type ): array`, `remove_preset_from_legacy_option( string $preset_id, string $module_name ): bool`; `sync_permission` = `UserRole::can_current_user_use_visual_builder() && current_user_can( 'edit_theme_options' )`. Weicht etwas ab, die Abweichung in Step 4 notieren und die betroffenen Tasks vor der Umsetzung anpassen. + +- [ ] **Step 2: Ablauf von `GlobalPresetController::sync` lesen** + +Run: `sed -n '/public static function sync/,/^\t}/p' Packages/GlobalData/GlobalPresetController.php` +Festhalten (für den Mirror in Task 5): ohne Chunks `prepare_data($presets)` → `get_data()` → `validate_preset_count($current, $prepared, $actionType)` → bei `DELETE_MODULE_PRESET`/`DELETE_OPTION_GROUP_PRESET` `get_deleted_preset_ids(…, 'module'|'group')` und je Eintrag `remove_preset_from_legacy_option($id, $moduleName)` → `save_data($prepared)`. + +- [ ] **Step 3: Gespeichertes Format am lebenden Bestand prüfen** + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-seed-stage4-design.php +ddev wp eval 'echo wp_json_encode(ET\Builder\Packages\GlobalData\GlobalPreset::get_data(), JSON_PRETTY_PRINT);' | head -80 +ddev wp eval '$m = ET\Builder\Packages\Conversion\Conversion::get_preset_attrs_mapping("divi/button"); echo wp_json_encode(array_slice($m, 0, 5, true), JSON_PRETTY_PRINT);' +``` +Expected: Speicherformat wie Formate §3 (`module`/`group` → Name → `{default, items: {id: item}}`, Einträge mit `type`, `id`, `name`, `moduleName`, `version`, `created`/`updated` in Millisekunden, `attrs`, `styleAttrs`, `renderAttrs`). Das Mapping ist ein Array Attributname → Einträge mit `attrName`, `preset` (Liste aus `style`/`html`/`script`/`content`). Abweichungen notieren. + +- [ ] **Step 4: Notiz nachführen** + +In `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` direkt nach der Einleitung einfügen: + +```markdown +## Nachtrag Divi 5.15.1 (Etappe 4a, 2026-10-16) + +Geprüft gegen Divi 5.15.1 (installiert, freigegeben): +- `GlobalPreset::save_data()` hat den zweiten Parameter `$skip_external_cache = false`; das Plugin übergibt ihn nicht. +- `GlobalPresetController::sync_permission()` verlangt jetzt **zusätzlich `edit_theme_options`** (5.2.1: nur Visual-Builder-Recht). Die Rechte in Spec §10 entsprechen damit Divi. +- Der Sync nimmt Chunks an (`requestId`, `chunkIndex`, `totalChunks`, Transient `et_global_preset_chunks__`); das Plugin schickt nie Chunks. +- Schrittfolge des Sync (ohne Chunks) unverändert: `prepare_data` → `get_data` → `validate_preset_count` → bei Löschaktionen `get_deleted_preset_ids` + `remove_preset_from_legacy_option` → `save_data`. Das Plugin wiederholt sie in `DesignSystemWriter::savePresets()` (Mirror `preset-sync`). +- +``` +Die letzte Zeile mit dem Ergebnis von Step 1–3 füllen (bei keiner Abweichung „Keine weiteren Abweichungen zu §3.“). + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md +git commit -m "docs: Divi-Formate der Presets gegen 5.15.1 geprüft" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: `PresetScope` und `PresetBook` – Preset-Bestand im Speicherformat + +**Files:** +- Create: `src/Integrations/Divi/Design/PresetScope.php` +- Create: `src/Integrations/Divi/Design/PresetBook.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetScopeTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` + +**Interfaces:** +- Consumes: `LIT\MCP\Abilities\Rev::of(string): string`. +- Produces: + - `final class PresetScope { public readonly string $kind; public readonly string $name; public function __construct(string $kind, string $name); public static function fromInput(array $item, string $field): self /* throws InvalidArgumentException */; public function label(): string; public function equals(self $other): bool }` – `$kind` ist `module` oder `group`, `$name` der volle Name (`divi/button`, `divi/font-body`). + - `final class PresetBook { public static function fromDivi(array $raw): self; public function data(): array; public function rev(): string; public function count(): int; public function items(PresetScope $scope): array /* id => item */; public function defaultId(PresetScope $scope): string; public function find(PresetScope $scope, string $idOrName): ?array /* throws InvalidArgumentException bei mehrdeutigem Namen */; public function idsNamed(PresetScope $scope, string $name): array; public function scopeOf(string $id): ?PresetScope; public function with(PresetScope $scope, array $item): self; public function withDefault(PresetScope $scope, string $id): self; public function without(PresetScope $scope, string $id): self; public function nestedUsers(string $groupPresetId): list; public function toRest(): array; public static function newId(): string }` + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/PresetScopeTest.php`: + +```php + 'button'], 'items[0]')->name); + self::assertSame('module', PresetScope::fromInput(['module' => 'divi/button'], 'items[0]')->kind); + $group = PresetScope::fromInput(['group' => 'font-body'], 'items[0]'); + self::assertSame(['group', 'divi/font-body'], [$group->kind, $group->name]); + self::assertSame('Gruppen-Preset divi/font-body', $group->label()); + self::assertTrue($group->equals(new PresetScope('group', 'divi/font-body'))); + } + + /** @return array, string}> */ + public static function invalid(): array + { + return [ + 'nothing' => [[], 'items[0]'], + 'both' => [['module' => 'button', 'group' => 'font'], 'items[0]'], + 'not a name' => [['module' => 'Button 1'], 'items[0].module'], + 'not a string' => [['group' => 5], 'items[0].group'], + 'two slashes' => [['module' => 'divi/a/b'], 'items[0].module'], + ]; + } + + /** + * @dataProvider invalid + * @param array $item + */ + public function testInvalidInputNamesTheField(array $item, string $field): void + { + try { + PresetScope::fromInput($item, 'items[0]'); + self::fail('expected an exception'); + } catch (InvalidArgumentException $e) { + self::assertStringStartsWith($field . ':', $e->getMessage()); + } + } +} +``` + +`tests/Unit/Integrations/Divi/Design/PresetBookTest.php`: + +```php + Stored format like GlobalPreset::get_data(). */ + public static function stored(): array + { + return [ + 'module' => [ + 'divi/button' => ['default' => '', 'items' => [ + 'aaaaaaaaaa' => ['type' => 'module', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär', + 'moduleName' => 'divi/button', 'attrs' => ['button' => ['x' => 1]]], + 'bbbbbbbbbb' => ['type' => 'module', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt', + 'moduleName' => 'divi/button', 'attrs' => []], + 'cccccccccc' => ['type' => 'module', 'id' => 'cccccccccc', 'name' => 'doppelt', + 'moduleName' => 'divi/button', 'attrs' => []], + ]], + 'divi/text' => ['default' => 'dddddddddd', 'items' => [ + 'dddddddddd' => ['type' => 'module', 'id' => 'dddddddddd', 'name' => 'Fließtext', + 'moduleName' => 'divi/text', 'attrs' => [], + 'groupPresets' => ['content.decoration.bodyFont' => [ + 'presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body']]], + ]], + ], + 'group' => [ + 'divi/font-body' => ['default' => '', 'items' => [ + 'gggggggggg' => ['type' => 'group', 'id' => 'gggggggggg', 'name' => 'Lesetext', + 'groupName' => 'divi/font-body', 'groupId' => 'content.decoration.bodyFont', + 'moduleName' => 'divi/text', 'attrs' => []], + ]], + ], + ]; + } + + public function testFindsByIdOrUniqueName(): void + { + $book = PresetBook::fromDivi(self::stored()); + $button = new PresetScope('module', 'divi/button'); + + self::assertSame('aaaaaaaaaa', $book->find($button, 'aaaaaaaaaa')['id']); + self::assertSame('aaaaaaaaaa', $book->find($button, 'cta PRIMÄR')['id']); + self::assertNull($book->find($button, 'Fehlt')); + self::assertNull($book->find(new PresetScope('module', 'divi/text'), 'aaaaaaaaaa')); + self::assertSame(['bbbbbbbbbb', 'cccccccccc'], $book->idsNamed($button, 'DOPPELT')); + } + + public function testAmbiguousNameListsTheIds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: bbbbbbbbbb, cccccccccc.'); + + PresetBook::fromDivi(self::stored())->find(new PresetScope('module', 'divi/button'), 'Doppelt'); + } + + public function testStdClassGroupsBecomeArraysAndCountCoversBothKinds(): void + { + $raw = self::stored(); + $raw['group'] = (object) ['divi/font-body' => (object) $raw['group']['divi/font-body']]; + $book = PresetBook::fromDivi($raw); + + self::assertSame(5, $book->count()); + self::assertIsArray($book->data()['group']['divi/font-body']['items']); + self::assertSame('group', $book->scopeOf('gggggggggg')?->kind); + self::assertNull($book->scopeOf('zzzzzzzzzz')); + self::assertSame(0, PresetBook::fromDivi(new stdClass())->count()); + } + + public function testWithWithoutAndDefaultAreImmutable(): void + { + $book = PresetBook::fromDivi(self::stored()); + $button = new PresetScope('module', 'divi/button'); + $new = ['type' => 'module', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'moduleName' => 'divi/button', 'attrs' => []]; + + $added = $book->with($button, $new)->withDefault($button, 'eeeeeeeeee'); + $removed = $added->without($button, 'eeeeeeeeee'); + + self::assertSame(5, $book->count()); + self::assertSame(6, $added->count()); + self::assertSame('eeeeeeeeee', $added->defaultId($button)); + self::assertSame('', $removed->defaultId($button), 'removing the default clears it'); + self::assertNotSame($book->rev(), $added->rev()); + self::assertSame($book->rev(), PresetBook::fromDivi(self::stored())->rev()); + self::assertSame('', $book->defaultId(new PresetScope('group', 'divi/spacing'))); + } + + public function testNestedUsersOfAGroupPreset(): void + { + $users = PresetBook::fromDivi(self::stored())->nestedUsers('gggggggggg'); + + self::assertCount(1, $users); + self::assertSame(['dddddddddd', 'Fließtext'], [$users[0]['id'], $users[0]['name']]); + self::assertSame('divi/text', $users[0]['scope']->name); + } + + public function testRestFormatListsItemsPerName(): void + { + $rest = PresetBook::fromDivi(self::stored())->toRest(); + + self::assertSame('dddddddddd', $rest['module'][1]['default']); + self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb', 'cccccccccc'], array_column($rest['module'][0]['items'], 'id')); + self::assertSame('gggggggggg', $rest['group'][0]['items'][0]['id']); + self::assertTrue(array_is_list($rest['module'])); + } + + public function testNewIdsLookLikeTheBuildersIds(): void + { + self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', PresetBook::newId()); + self::assertNotSame(PresetBook::newId(), PresetBook::newId()); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php` +Expected: FAIL, `Class "LIT\MCP\Integrations\Divi\Design\PresetScope" not found`. + +- [ ] **Step 3: `PresetScope` implementieren** + +```php +) or an option group preset + * ("group" + divi/). Input takes the short ("button", "font-body") or the full name. Pure PHP. + */ +final class PresetScope +{ + public const MODULE = 'module'; + public const GROUP = 'group'; + + private const NAME = '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/D'; + + public function __construct(public readonly string $kind, public readonly string $name) + { + } + + /** + * @param array $item An input entry with exactly one of "module" or "group". + * @throws InvalidArgumentException ": " for the input path. + */ + public static function fromInput(array $item, string $field): self + { + $kinds = array_values(array_filter( + [self::MODULE, self::GROUP], + static fn (string $kind): bool => array_key_exists($kind, $item) + )); + if (count($kinds) !== 1) { + throw new InvalidArgumentException($field . ': genau eines von module oder group angeben'); + } + + $kind = $kinds[0]; + $value = $item[$kind]; + $name = is_string($value) ? (str_contains($value, '/') ? $value : 'divi/' . $value) : ''; + if (preg_match(self::NAME, $name) !== 1) { + throw new InvalidArgumentException(sprintf( + '%s.%s: Name wie "button" bzw. "divi/button" (Gruppe: "font-body") erwartet', + $field, + $kind + )); + } + + return new self($kind, $name); + } + + public function label(): string + { + return ($this->kind === self::MODULE ? 'Modul-Preset ' : 'Gruppen-Preset ') . $this->name; + } + + public function equals(self $other): bool + { + return $this->kind === $other->kind && $this->name === $other->name; + } +} +``` + +- [ ] **Step 4: `PresetBook` implementieren** + +```php +: {default: id|"", items: {id: item}}}}. Immutable – with()/without()/withDefault() return a + * new book. rev() is the design_rev of the area "presets" (spec §2). toRest() gives the REST/sync format + * ({module|group: list<{default, items: list}>}) that GlobalPreset::prepare_data() takes. Pure PHP. + */ +final class PresetBook +{ + private const KINDS = [PresetScope::MODULE, PresetScope::GROUP]; + + private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + + /** @param array{module: array>}>, + * group: array>}>} $data */ + private function __construct(private array $data) + { + } + + /** @param array|object $raw GlobalPreset::get_data() – groups may be stdClass. */ + public static function fromDivi(array|object $raw): self + { + $raw = self::plain($raw); + $data = [PresetScope::MODULE => [], PresetScope::GROUP => []]; + foreach (self::KINDS as $kind) { + foreach (is_array($raw[$kind] ?? null) ? $raw[$kind] : [] as $name => $record) { + if (! is_array($record)) { + continue; + } + $items = []; + foreach (is_array($record['items'] ?? null) ? $record['items'] : [] as $id => $item) { + if (is_array($item)) { + $items[(string) $id] = $item; + } + } + $data[$kind][(string) $name] = [ + 'default' => is_string($record['default'] ?? null) ? $record['default'] : '', + 'items' => $items, + ]; + } + } + + return new self($data); + } + + /** @return array */ + public function data(): array + { + return $this->data; + } + + public function rev(): string + { + return Rev::of('presets' . "\0" . (string) json_encode($this->data, JSON_UNESCAPED_UNICODE)); + } + + public function count(): int + { + $count = 0; + foreach ($this->data as $records) { + foreach ($records as $record) { + $count += count($record['items']); + } + } + + return $count; + } + + /** @return array> id => item */ + public function items(PresetScope $scope): array + { + return $this->data[$scope->kind][$scope->name]['items'] ?? []; + } + + public function defaultId(PresetScope $scope): string + { + return $this->data[$scope->kind][$scope->name]['default'] ?? ''; + } + + /** + * The preset with ID $idOrName, else the one preset named $idOrName (case-insensitive). + * + * @return array|null + * @throws InvalidArgumentException When several presets of $scope share the name (ruling S13a). + */ + public function find(PresetScope $scope, string $idOrName): ?array + { + $items = $this->items($scope); + if (isset($items[$idOrName])) { + return $items[$idOrName]; + } + + $ids = $this->idsNamed($scope, $idOrName); + if (count($ids) > 1) { + throw new InvalidArgumentException(sprintf( + 'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.', + trim($idOrName), + implode(', ', $ids) + )); + } + + return $ids === [] ? null : $items[$ids[0]]; + } + + /** @return list IDs of the presets of $scope named $name (case-insensitive, trimmed). */ + public function idsNamed(PresetScope $scope, string $name): array + { + $key = mb_strtolower(trim($name)); + $ids = []; + foreach ($this->items($scope) as $id => $item) { + if (is_string($item['name'] ?? null) && mb_strtolower(trim($item['name'])) === $key) { + $ids[] = (string) $id; + } + } + + return $ids; + } + + public function scopeOf(string $id): ?PresetScope + { + foreach ($this->data as $kind => $records) { + foreach ($records as $name => $record) { + if (isset($record['items'][$id])) { + return new PresetScope($kind, $name); + } + } + } + + return null; + } + + /** @param array $item Needs a string "id"; added or replaced. */ + public function with(PresetScope $scope, array $item): self + { + $data = $this->data; + $data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []]; + $data[$scope->kind][$scope->name]['items'][(string) $item['id']] = $item; + + return new self($data); + } + + public function withDefault(PresetScope $scope, string $id): self + { + $data = $this->data; + $data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []]; + $data[$scope->kind][$scope->name]['default'] = $id; + + return new self($data); + } + + /** Removes the preset; a default that pointed to it becomes "" (spec §6.4). */ + public function without(PresetScope $scope, string $id): self + { + $data = $this->data; + unset($data[$scope->kind][$scope->name]['items'][$id]); + if (($data[$scope->kind][$scope->name]['default'] ?? '') === $id) { + $data[$scope->kind][$scope->name]['default'] = ''; + } + + return new self($data); + } + + /** + * Presets that nest group preset $groupPresetId in their groupPresets (formats §3). + * + * @return list + */ + public function nestedUsers(string $groupPresetId): array + { + $users = []; + foreach ($this->data as $kind => $records) { + foreach ($records as $name => $record) { + foreach ($record['items'] as $id => $item) { + foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $nested) { + $ids = is_array($nested) ? (array) ($nested['presetId'] ?? []) : []; + if (in_array($groupPresetId, $ids, true)) { + $users[] = [ + 'scope' => new PresetScope($kind, $name), + 'id' => (string) $id, + 'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $id, + ]; + break; + } + } + } + } + } + + return $users; + } + + /** @return array{module: list>}>, + * group: list>}>} */ + public function toRest(): array + { + $rest = [PresetScope::MODULE => [], PresetScope::GROUP => []]; + foreach ($this->data as $kind => $records) { + foreach ($records as $record) { + $rest[$kind][] = ['default' => $record['default'], 'items' => array_values($record['items'])]; + } + } + + return $rest; + } + + /** A new preset ID like the Visual Builder's generateNanoUuid(): 10 characters of [0-9a-z] (formats §3). */ + public static function newId(): string + { + $id = ''; + for ($i = 0; $i < 10; ++$i) { + $id .= self::ALPHABET[random_int(0, 35)]; + } + + return $id; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } +} +``` + +- [ ] **Step 5: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php` +Expected: PASS (alle Tests grün). + +- [ ] **Step 6: Gesamtlauf und Lint** + +Run: `composer test && composer lint` +Expected: OK, keine phpcs-Fehler. + +- [ ] **Step 7: Commit** + +```bash +git add src/Integrations/Divi/Design/PresetScope.php src/Integrations/Divi/Design/PresetBook.php tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php +git commit -m "feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `PresetDiff` – Unterschied zweier Preset-Bestände + +**Files:** +- Create: `src/Integrations/Divi/Design/PresetDiff.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` + +**Interfaces:** +- Consumes: `PresetBook::data()`, `PresetScope` (Task 2). +- Produces: `final class PresetDiff { public static function between(PresetBook $before, PresetBook $after): list}> }` – `action` ∈ `added`, `changed`, `removed`, `default_set`, `default_cleared`; bei `changed` listet `fields` die geänderten Schlüssel der obersten Ebene (`attrs`, `name`, `groupPresets` …; `updated`, `styleAttrs`, `renderAttrs` und `version` zählen nicht). + +- [ ] **Step 1: Failing test schreiben** + +```php +items($button)['aaaaaaaaaa']; + $changed['attrs'] = ['button' => ['x' => 2]]; + $changed['updated'] = 99; + $changed['styleAttrs'] = ['ignored' => true]; + $after = $before + ->with($button, $changed) + ->with($button, ['id' => 'eeeeeeeeee', 'name' => 'Neu', 'attrs' => []]) + ->withDefault($button, 'eeeeeeeeee') + ->without($button, 'bbbbbbbbbb') + ->withDefault($text, ''); + + self::assertSame([ + ['kind' => 'module', 'scope' => 'divi/button', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär', + 'action' => 'changed', 'fields' => ['attrs']], + ['kind' => 'module', 'scope' => 'divi/button', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt', + 'action' => 'removed'], + ['kind' => 'module', 'scope' => 'divi/button', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'action' => 'added'], + ['kind' => 'module', 'scope' => 'divi/button', 'id' => 'eeeeeeeeee', 'name' => 'Neu', + 'action' => 'default_set'], + ['kind' => 'module', 'scope' => 'divi/text', 'id' => 'dddddddddd', 'name' => 'Fließtext', + 'action' => 'default_cleared'], + ], PresetDiff::between($before, $after)); + } +} +``` + +- [ ] **Step 2: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` +Expected: FAIL, `Class "LIT\MCP\Integrations\Divi\Design\PresetDiff" not found`. + +- [ ] **Step 3: Implementieren** + +```php +}> */ + public static function between(PresetBook $before, PresetBook $after): array + { + $old = $before->data(); + $new = $after->data(); + $rows = []; + foreach ([PresetScope::MODULE, PresetScope::GROUP] as $kind) { + $names = array_unique(array_merge(array_keys($old[$kind] ?? []), array_keys($new[$kind] ?? []))); + sort($names); + foreach ($names as $name) { + $was = $old[$kind][$name] ?? ['default' => '', 'items' => []]; + $is = $new[$kind][$name] ?? ['default' => '', 'items' => []]; + array_push($rows, ...self::items($kind, (string) $name, $was['items'], $is['items'])); + array_push($rows, ...self::defaults($kind, (string) $name, $was, $is)); + } + } + + return $rows; + } + + /** + * @param array> $was + * @param array> $is + * @return list> + */ + private static function items(string $kind, string $scope, array $was, array $is): array + { + $ids = array_unique(array_merge(array_keys($was), array_keys($is))); + sort($ids); + $rows = []; + foreach ($ids as $id) { + $id = (string) $id; + $before = $was[$id] ?? null; + $after = $is[$id] ?? null; + if ($before === null || $after === null) { + $rows[] = self::row($kind, $scope, $id, $after ?? $before, $before === null ? 'added' : 'removed'); + continue; + } + $fields = self::changedFields($before, $after); + if ($fields !== []) { + $rows[] = self::row($kind, $scope, $id, $after, 'changed') + ['fields' => $fields]; + } + } + + return $rows; + } + + /** + * @param array{default: string, items: array>} $was + * @param array{default: string, items: array>} $is + * @return list> + */ + private static function defaults(string $kind, string $scope, array $was, array $is): array + { + if ($was['default'] === $is['default']) { + return []; + } + if ($is['default'] === '') { + return [self::row($kind, $scope, $was['default'], $was['items'][$was['default']] ?? null, 'default_cleared')]; + } + + return [self::row($kind, $scope, $is['default'], $is['items'][$is['default']] ?? null, 'default_set')]; + } + + /** + * @param array $before + * @param array $after + * @return list + */ + private static function changedFields(array $before, array $after): array + { + $keys = array_diff(array_unique(array_merge(array_keys($before), array_keys($after))), self::DERIVED); + $fields = []; + foreach ($keys as $key) { + if (($before[$key] ?? null) != ($after[$key] ?? null)) { // phpcs:ignore Universal.Operators.StrictComparisons + $fields[] = (string) $key; + } + } + sort($fields); + + return $fields; + } + + /** + * @param array|null $item + * @return array{kind: string, scope: string, id: string, name: string, action: string} + */ + private static function row(string $kind, string $scope, string $id, ?array $item, string $action): array + { + return [ + 'kind' => $kind, + 'scope' => $scope, + 'id' => $id, + 'name' => is_string($item['name'] ?? null) ? $item['name'] : $id, + 'action' => $action, + ]; + } +} +``` + +Hinweis zur losen Gleichheit in `changedFields()`: Divi speichert Zahlen mal als String, mal als Zahl (`order: "1"`); `!=` vergleicht Arrays rekursiv ohne Typ- und Schlüsselreihenfolge-Strenge – genau das gewünschte „inhaltlich gleich“. Meldet phpcs eine andere Regel als `Universal.Operators.StrictComparisons`, den Regelnamen im `phpcs:ignore` daran anpassen. + +Die erwartete Reihenfolge im Test folgt aus der Sortierung: je Bereich zuerst alle Einträge nach ID (`aaaaaaaaaa` changed, `bbbbbbbbbb` removed, `eeeeeeeeee` added), dann der Standard. + +- [ ] **Step 4: Test laufen lassen – er besteht** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/PresetDiff.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php +git commit -m "feat: PresetDiff – Unterschied zweier Preset-Bestände für den Probelauf" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: `SnapshotStore` – Snapshots je Bereich (Spec §5) + +**Files:** +- Create: `src/Integrations/Divi/Design/SnapshotStore.php` +- Create: `src/Integrations/Divi/Design/SnapshotTooLarge.php` +- Modify: `src/Uninstaller.php` (Konstante `OPTIONS`) +- Test: `tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php` +- Test: `tests/Unit/UninstallerTest.php` + +**Interfaces:** +- Consumes: WordPress `get_option`, `update_option`, `get_current_user_id`, `gmdate`. +- Produces: + - `class SnapshotStore { public const AREAS = ['colors', 'variables', 'presets', 'theme_options']; public const LIMIT = 20; public const MAX_BYTES = 2000000; public static function option(string $area): string; public function save(string $area, array $data, string $ability): string /* id; throws SnapshotTooLarge, RuntimeException */; public function attachLog(string $area, string $id, ?int $logId): void; public function list(string $area): list; public function get(string $area, string $id): ?array /* Eintrag mit data */ }` – nicht final (Mocks in Ability-Tests). + - `final class SnapshotTooLarge extends RuntimeException { public function __construct(public readonly int $size) }` + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php`: + +```php + */ + private array $options = []; + + protected function setUp(): void + { + parent::setUp(); + $this->options = []; + Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default); + Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { + self::assertFalse($autoload, 'snapshots are never autoloaded'); + $this->options[$name] = $value; + + return true; + }); + Functions\when('get_current_user_id')->justReturn(7); + } + + public function testSavesListsAndReturnsTheNewestFirst(): void + { + $store = new SnapshotStore(); + $first = $store->save('presets', ['module' => ['a' => 1]], 'lit/divi-presets-set'); + $second = $store->save('presets', ['module' => ['a' => 2]], 'lit/divi-presets-set'); + $store->attachLog('presets', $second, 42); + + self::assertSame('lit_mcp_snapshots_presets', SnapshotStore::option('presets')); + self::assertMatchesRegularExpression('/^presets-\d{8}T\d{6}Z-[0-9a-f]{6}$/', $first); + $list = $store->list('presets'); + self::assertSame([$second, $first], array_column($list, 'id')); + self::assertSame(42, $list[0]['log_id']); + self::assertSame(7, $list[0]['user_id']); + self::assertArrayNotHasKey('data', $list[0]); + self::assertSame(['module' => ['a' => 1]], $store->get('presets', $first)['data']); + self::assertNull($store->get('presets', 'presets-unknown')); + self::assertSame([], $store->list('colors')); + } + + public function testKeepsAtMostTwentyPerArea(): void + { + $store = new SnapshotStore(); + $ids = []; + for ($i = 0; $i < 22; ++$i) { + $ids[] = $store->save('presets', ['n' => $i], 'lit/divi-presets-set'); + } + + $list = $store->list('presets'); + self::assertCount(SnapshotStore::LIMIT, $list); + self::assertSame($ids[21], $list[0]['id']); + self::assertNull($store->get('presets', $ids[0])); + self::assertNull($store->get('presets', $ids[1])); + } + + public function testRefusesSnapshotsAboveTheLimit(): void + { + $this->expectException(SnapshotTooLarge::class); + + (new SnapshotStore())->save('presets', ['x' => str_repeat('a', SnapshotStore::MAX_BYTES)], 'lit/divi-presets-set'); + } + + public function testUnknownAreaIsAProgrammingError(): void + { + $this->expectException(InvalidArgumentException::class); + + SnapshotStore::option('pages'); + } + + public function testAFailedOptionWriteThrows(): void + { + Functions\when('update_option')->justReturn(false); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('snapshot presets could not be stored'); + + (new SnapshotStore())->save('presets', ['a' => 1], 'lit/divi-presets-set'); + } + + public function testCorruptOptionReadsAsEmpty(): void + { + $this->options['lit_mcp_snapshots_presets'] = 'kaputt'; + + self::assertSame([], (new SnapshotStore())->list('presets')); + } +} +``` + +`update_option()` liefert `false`, wenn der Wert unverändert ist. Ein neuer Snapshot ändert die Liste immer (neue ID am Anfang), daher ist `false` hier ein echter Fehler. + +In `tests/Unit/UninstallerTest.php` den Test, der die gelöschten Optionen prüft, um die vier Snapshot-Optionen ergänzen. Dafür in der Liste der erwarteten Optionen (Suche nach `'lit_mcp_divi_check_lock'`) anhängen: + +```php +'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', +'lit_mcp_snapshots_theme_options', +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php` +Expected: FAIL (`SnapshotStore` fehlt; Uninstaller löscht die neuen Optionen nicht). + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Design/SnapshotTooLarge.php`: + +```php +, never autoloaded), + * newest first, at most LIMIT entries – the oldest falls out. An entry keeps the area's raw inventory as Divi stores + * it plus who made it with which tool and the change log entry. The only way back for colors, variables and presets: + * Divi 5 keeps no server-side history (formats §7.6). Not final so abilities can be tested with a mock. + */ +class SnapshotStore +{ + public const AREAS = ['colors', 'variables', 'presets', 'theme_options']; + + public const LIMIT = 20; + + /** Spec §5: a larger snapshot refuses the change. */ + public const MAX_BYTES = 2000000; + + private const PREFIX = 'lit_mcp_snapshots_'; + + public static function option(string $area): string + { + if (! in_array($area, self::AREAS, true)) { + throw new InvalidArgumentException(sprintf('Unknown design area "%s".', $area)); + } + + return self::PREFIX . $area; + } + + /** + * @param array $data The area's raw inventory. + * @throws SnapshotTooLarge + * @throws RuntimeException When the option could not be written. + */ + public function save(string $area, array $data, string $ability): string + { + $size = strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + if ($size > self::MAX_BYTES) { + throw new SnapshotTooLarge($size); + } + + $id = sprintf('%s-%s-%s', $area, gmdate('Ymd\THis\Z'), bin2hex(random_bytes(3))); + $entries = $this->entries($area); + array_unshift($entries, [ + 'id' => $id, + 'created' => gmdate('c'), + 'user_id' => get_current_user_id(), + 'ability' => $ability, + 'log_id' => null, + 'size' => $size, + 'data' => $data, + ]); + if (! update_option(self::option($area), array_slice($entries, 0, self::LIMIT), false)) { + throw new RuntimeException(sprintf('snapshot %s could not be stored', $area)); + } + + return $id; + } + + /** Notes the change log entry of the change the snapshot belongs to (the log entry is written after it). */ + public function attachLog(string $area, string $id, ?int $logId): void + { + if ($logId === null) { + return; + } + + $entries = $this->entries($area); + foreach ($entries as $index => $entry) { + if ($entry['id'] === $id) { + $entries[$index]['log_id'] = $logId; + update_option(self::option($area), $entries, false); + + return; + } + } + } + + /** @return list */ + public function list(string $area): array + { + return array_map(static function (array $entry): array { + unset($entry['data']); + + return $entry; + }, $this->entries($area)); + } + + /** @return array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int, + * data: array}|null */ + public function get(string $area, string $id): ?array + { + foreach ($this->entries($area) as $entry) { + if ($entry['id'] === $id) { + return $entry; + } + } + + return null; + } + + /** @return list}> */ + private function entries(string $area): array + { + $stored = get_option(self::option($area), []); + if (! is_array($stored)) { + return []; + } + + $entries = []; + foreach ($stored as $entry) { + if (is_array($entry) && is_string($entry['id'] ?? null) && is_array($entry['data'] ?? null)) { + $entries[] = [ + 'id' => $entry['id'], + 'created' => (string) ($entry['created'] ?? ''), + 'user_id' => (int) ($entry['user_id'] ?? 0), + 'ability' => (string) ($entry['ability'] ?? ''), + 'log_id' => isset($entry['log_id']) ? (int) $entry['log_id'] : null, + 'size' => (int) ($entry['size'] ?? 0), + 'data' => $entry['data'], + ]; + } + } + + return $entries; + } +} +``` + +In `src/Uninstaller.php` die Konstante `OPTIONS` erweitern: + +```php + private const OPTIONS = [ + 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', + 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', + 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', + 'lit_mcp_snapshots_theme_options', + ]; +``` + +Den Docblock von `Uninstaller` um „Snapshots des Designsystems (Etappe 4)“ in der Aufzählung der entfernten Daten ergänzen. + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/SnapshotStore.php src/Integrations/Divi/Design/SnapshotTooLarge.php src/Uninstaller.php tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php +git commit -m "feat: SnapshotStore – Snapshots des Designsystems je Bereich" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 5: Adapter `DesignSystemWriter` – Presets über Divis Sync-Ablauf schreiben + +**Files:** +- Create: `src/Integrations/Divi/Design/DesignSystemWriter.php` +- Modify: `src/Integrations/Divi/Release/DiviAdapters.php` +- Modify: `src/Integrations/Divi/Release/fingerprints.json` (per WP-CLI erzeugt) +- Create: `tests/Fixtures/divi/reference/preset-sync.txt` (per WP-CLI erzeugt) +- Test: `tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php` + +**Interfaces:** +- Consumes: `ReleaseGate::assertWritable()`, `Manifest`, `Call`, `Mirror` (vorhanden). +- Produces: `class DesignSystemWriter implements DiviAdapter` (nicht final) mit + - `public function presets(): array` – roher Bestand (`GlobalPreset::get_data()`). + - `public function savePresets(array $rest, bool $mayRemove): array` – REST-Format (`PresetBook::toRest()`) speichern wie Divis Sync; `$mayRemove` erlaubt weniger Presets (Löschen, Wiederherstellen) und bereinigt dann Divis Divi-4-Altoption; gibt den gespeicherten Bestand zurück; `@throws DiviLocked`, `@throws RuntimeException` (Divi meldet einen Fehler). + - `public function presetAttrs(string $moduleName, array $attrs): array{all: array, style: array, render: array}` – Teilmengen nach Divis Preset-Mapping. + - `public function canUseVisualBuilder(): bool`. + - Konstanten `ACTION_SAVE = 'ADD_NEW_MODULE_PRESET'`, `ACTION_DELETE_MODULE = 'DELETE_MODULE_PRESET'`, `ACTION_DELETE_GROUP = 'DELETE_OPTION_GROUP_PRESET'`, `PRESET_TYPES = ['style', 'html', 'script']`. + +- [ ] **Step 1: Failing test schreiben** + +```php +area); + self::assertSame( + [ + 'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()', + 'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()', + 'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()', + 'UserRole::can_current_user_use_visual_builder()', + ], + array_map(static fn (Call $call): string => $call->label(), $manifest->calls) + ); + self::assertSame(['preset-sync'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors)); + self::assertSame('GlobalPresetController', $manifest->mirrors[0]->class); + } + + public function testWritesNeedAReleasedDivi(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0')); + + $this->expectException(DiviLocked::class); + + (new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false); + } + + public function testWithoutDiviNobodyUsesTheVisualBuilder(): void + { + self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder()); + } +} +``` + +`UserRole` ist im Unit-Test nicht geladen; `canUseVisualBuilder()` prüft `class_exists()` und antwortet dann `false` (fail closed). + +- [ ] **Step 2: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php` +Expected: FAIL, Klasse fehlt. + +- [ ] **Step 3: Implementieren** + +```php + The stored inventory (GlobalPreset::get_data(), formats §3). */ + public function presets(): array + { + $data = GlobalPreset::get_data(); + + return is_array($data) ? $data : []; + } + + /** + * @param array $rest REST/sync format, see PresetBook::toRest(). + * @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones + * leave Divi's legacy option (like a DELETE_* sync). + * @return array The inventory Divi stored. + * @throws DiviLocked + * @throws RuntimeException When Divi refuses the inventory. + */ + public function savePresets(array $rest, bool $mayRemove): array + { + ReleaseGate::assertWritable(); + $prepared = GlobalPreset::prepare_data($rest); + $current = GlobalPreset::get_data(); + $refusal = GlobalPreset::validate_preset_count( + $current, + $prepared, + $mayRemove ? self::ACTION_DELETE_MODULE : self::ACTION_SAVE + ); + if ($refusal instanceof WP_Error) { + throw new RuntimeException('preset sync refused: ' . $refusal->get_error_code() . ' ' + . $refusal->get_error_message()); + } + + if ($mayRemove) { + foreach (['module', 'group'] as $kind) { + foreach (GlobalPreset::get_deleted_preset_ids($current, $prepared, $kind) as $deleted) { + GlobalPreset::remove_preset_from_legacy_option( + (string) ($deleted['id'] ?? ''), + (string) ($deleted['moduleName'] ?? '') + ); + } + } + } + + $saved = GlobalPreset::save_data($prepared); + + return is_array($saved) ? $saved : []; + } + + /** + * The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style + * (styleAttrs) and render (renderAttrs: html + script). + * + * @param array $attrs + * @return array{all: array, style: array, render: array} + */ + public function presetAttrs(string $moduleName, array $attrs): array + { + $map = Conversion::get_preset_attrs_mapping($moduleName); + + return [ + 'all' => (array) Conversion::get_preset_attrs($attrs, self::PRESET_TYPES, $map), + 'style' => (array) Conversion::get_preset_attrs($attrs, ['style'], $map), + 'render' => (array) Conversion::get_preset_attrs($attrs, ['html', 'script'], $map), + ]; + } + + /** Divi's role right "Visual Builder verwenden"; fail closed without Divi. */ + public function canUseVisualBuilder(): bool + { + return class_exists(UserRole::class) && UserRole::can_current_user_use_visual_builder(); + } +} +``` + +In `src/Integrations/Divi/Release/DiviAdapters.php` `DesignSystemWriter::class` direkt nach `DesignSystemReader::class` in `CLASSES` aufnehmen und importieren (`use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;`). + +- [ ] **Step 4: Unit-Tests laufen lassen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php tests/Unit/Integrations/Divi/Release` +Expected: PASS bis auf Tests, die den Fingerabdruck jedes Mirrors in `fingerprints.json` erwarten (z. B. „Mirror ohne Soll-Fingerabdruck“) – die werden in Step 5 grün. + +- [ ] **Step 5: Fingerabdruck und Referenz erzeugen** + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +ddev wp --user=admin lit-mcp divi-check --update-reference +cd wp-content/plugins/WP-LIT-MCP && git diff --stat src/Integrations/Divi/Release/fingerprints.json && ls tests/Fixtures/divi/reference/preset-sync.txt +``` +Expected: `fingerprints.json` bekommt genau den neuen Schlüssel `preset-sync` (die übrigen Werte bleiben); `tests/Fixtures/divi/reference/preset-sync.txt` existiert und enthält den normalisierten Quelltext von `GlobalPresetController::sync`. Ändern sich andere Schlüssel, nicht committen, sondern prüfen (installiertes Divi ≠ freigegebene Version). + +- [ ] **Step 6: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` +Expected: OK; `DiviBoundaryTest` grün (alle Divi-Namen des Writers stehen im Manifest). + +```bash +git add src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Release/DiviAdapters.php src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/preset-sync.txt tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php +git commit -m "feat: DesignSystemWriter – Presets über Divis Sync-Ablauf schreiben (Mirror preset-sync)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: `GlobalUsages` mit austauschbarer Suche (`UsageQuery`, `LibraryUsageQuery`) + +Refaktorierung ohne Verhaltensänderung: Rechte, Grenzen und Bericht von `GlobalUsages` werden für beliebige Verweise nutzbar; die bisherige Suche nach Bibliotheksverweisen wird `LibraryUsageQuery`. + +**Files:** +- Create: `src/Integrations/Divi/Library/UsageQuery.php` +- Create: `src/Integrations/Divi/Library/LibraryUsageQuery.php` +- Modify: `src/Integrations/Divi/Library/GlobalUsages.php` +- Test: `tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` +- Test: `tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php` + +**Interfaces:** +- Consumes: `UsageMatcher::shows()` (vorhanden). +- Produces: + - `interface UsageQuery { public function candidateCondition(\wpdb $db): string; public function matches(string $content): bool; }` – `candidateCondition()` liefert eine **fertig vorbereitete** SQL-Bedingung (mit `$db->prepare()`), die Kandidaten über `post_content` vorfiltert; `matches()` entscheidet am Inhalt (fail closed). + - `final class LibraryUsageQuery implements UsageQuery { public function __construct(int $libraryId) }`. + - `GlobalUsages::reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport` und `GlobalUsages::rowsFor(UsageQuery $query): list`; `report(int, bool)` und `rows(int)` bleiben und delegieren an `new LibraryUsageQuery($id)`. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php`: + +```php +allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)', + '%globalmodule%', + '%global_module%', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + self::assertSame('(prepared)', (new LibraryUsageQuery(12))->candidateCondition($db)); + } + + public function testMatchesThroughTheUsageMatcher(): void + { + $query = new LibraryUsageQuery(12); + + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + } +} +``` + +In `tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` ergänzen (Aufbau der vorhandenen `$wpdb`-Mocks des Tests übernehmen; der Test erwartet, dass die Kandidatenabfrage die Bedingung der Suche enthält): + +```php + public function testRowsForUsesTheConditionAndMatcherOfTheQuery(): void + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $queries = []; + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { + $queries[] = $sql; + + return str_contains($sql, 'post_content FROM') + ? [['ID' => '5', 'post_content' => 'treffer'], ['ID' => '6', 'post_content' => 'nichts']] + : [['ID' => '5', 'post_status' => 'draft', 'post_type' => 'page'], + ['ID' => '6', 'post_status' => 'draft', 'post_type' => 'page']]; + }); + $query = new class implements \LIT\MCP\Integrations\Divi\Library\UsageQuery { + public function candidateCondition(\wpdb $db): string + { + return '(MARKER)'; + } + + public function matches(string $content): bool + { + return $content === 'treffer'; + } + }; + + $rows = (new GlobalUsages($db))->rowsFor($query); + + self::assertSame([['id' => 5, 'status' => 'draft', 'type' => 'page']], $rows); + self::assertStringContainsString('WHERE (MARKER) AND post_type', $queries[0]); + } +``` + +Der bestehende `testFindsEveryFormatDiviRendersFromCandidates` bleibt unverändert und muss grün bleiben: `LibraryUsageQuery` erzeugt mit dem echten `prepare()` des `wpdb`-Stubs wörtlich dieselbe Kandidaten-SQL wie bisher. + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` +Expected: FAIL (`LibraryUsageQuery`/`UsageQuery` fehlen, `rowsFor()` unbekannt). + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/UsageQuery.php`: + +```php +prepare(). */ + public function candidateCondition(\wpdb $db): string; + + /** Whether $content references the thing; true when unsure (fail closed). */ + public function matches(string $content): bool; +} +``` + +`src/Integrations/Divi/Library/LibraryUsageQuery.php`: + +```php +prepare( + '(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)', + '%' . $db->esc_like('globalmodule') . '%', + '%' . $db->esc_like('global_module') . '%', + str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]' + ); + } + + public function matches(string $content): bool + { + return UsageMatcher::shows($content, $this->libraryId); + } +} +``` + +In `src/Integrations/Divi/Library/GlobalUsages.php`: + +1. `report()`, `rows()` und die neuen Methoden – `report()` ruft weiter `rows()` auf, damit bestehende Tests, die `rows()` überschreiben, wirksam bleiben: + +```php + /** + * The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post + * on every live post) – the dry run and the confirm of an edit need it, a listing does not. + */ + public function report(int $libraryId, bool $withBlocked = true): UsageReport + { + return $this->reportOfRows($this->rows($libraryId), $withBlocked); + } + + /** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */ + public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport + { + return $this->reportOfRows($this->rowsFor($query), $withBlocked); + } + + /** @return list */ + public function rows(int $libraryId): array + { + return $this->rowsFor(new LibraryUsageQuery($libraryId)); + } + + /** + * Every post (no revision, not trashed) whose content $query matches (fail closed). Candidates come from one + * query (ID, status and type only); their content is read in chunks of CONTENT_CHUNK; beyond PARSE_CAP + * candidates the rest counts as references unread (audit L1). + * + * @return list + */ + public function rowsFor(UsageQuery $query): array + { + $candidates = $this->candidates($query); + $result = []; + foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) { + $contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk)); + foreach ($chunk as $row) { + if (! isset($contents[$row['id']]) || $query->matches($contents[$row['id']])) { + $result[] = $row; + } + } + } + + return array_merge($result, array_slice($candidates, self::PARSE_CAP)); + } +``` + +2. Der bisherige Rumpf von `report()` ab der Zeile `$live = count(array_filter($rows, …));` bis zum `return new UsageReport(…);` wird **wörtlich** in die neue private Methode verschoben: + +```php + /** @param list $rows */ + private function reportOfRows(array $rows, bool $withBlocked): UsageReport + { + $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); + // … ab hier die bisherigen Zeilen von report() bis einschließlich "return new UsageReport(…);" … + } +``` + +3. `candidates()` nimmt die Suche: + +```php + /** @return list */ + private function candidates(UsageQuery $query): array + { + $db = $this->db(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $rows = $db->get_results( + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db) + . " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', + 'ARRAY_A' + ); + // phpcs:enable + // … unveränderte Umwandlung in $result … + } +``` + +4. Klassen-Docblock: erster Satz „Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes – a library entry $id (LibraryUsageQuery) or, since Etappe 4, a preset: …“; der Rest bleibt. + +Alle übrigen Erwartungen von `GlobalUsagesTest` (Kandidaten-SQL, Rechte, Grenzen, Theme Builder) bleiben unverändert. + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library` +Expected: PASS, alle Bibliotheks-Tests grün. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Library/UsageQuery.php src/Integrations/Divi/Library/LibraryUsageQuery.php src/Integrations/Divi/Library/GlobalUsages.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php +git commit -m "refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Fundstellen eines Presets – `PresetUsageMatcher`, `PresetUsageQuery` + +**Files:** +- Create: `src/Integrations/Divi/Design/PresetUsageMatcher.php` +- Create: `src/Integrations/Divi/Design/PresetUsageQuery.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php` + +**Interfaces:** +- Consumes: `Parser::parse()`, `MalformedMarkup`, `NodeIndex::build()`, `NodeIndex::entries()`, `NodeSummary::presetIds()` (vorhanden), `PresetScope` (Task 2), `UsageQuery` (Task 6). +- Produces: + - `final class PresetUsageMatcher { public function __construct(private Parser $parser = new Parser()); public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool }` – Modul-Preset: ein Block von `$scope->name` mit `$id` in `modulePreset` oder – wenn `$isDefault` – ohne eigenes Modul-Preset (bzw. `""`/`default`/`_initial`); Gruppen-Preset: ein Block mit `$id` in einem `groupPreset.*.presetId` mit `groupName` `$scope->name` oder – wenn `$isDefault` – ein Block mit dieser Gruppe ohne eigenes Gruppen-Preset ist **nicht** Teil von 4a (siehe Hinweis); fail closed bei kaputtem Markup. + - `final class PresetUsageQuery implements UsageQuery { public function __construct(array $targets, PresetUsageMatcher $matcher = new PresetUsageMatcher()) }` – `$targets`: `list`, nicht leer; ein Beitrag zählt, wenn er **eines** der Presets verwendet (ein Aufruf von `lit/divi-presets-set` kann mehrere Presets ändern). + +Hinweis: Welche Module ein **Standard-Gruppen-Preset** erreicht, hängt von Divis Gruppenliste je Modul ab (`get_group_preset_default_attr`, Formate §3) – das kommt mit der Gruppen-Zuweisung in 4b. In 4a meldet der Probelauf für ein Standard-Gruppen-Preset nur die Seiten mit expliziter Zuweisung und warnt: „Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser Gruppe; diese Seiten sind nicht aufgelistet.“ + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php`: + +```php +' + . ''; + + private static function page(string $buttonAttrs): string + { + return sprintf(self::BUTTON, $buttonAttrs); + } + + public function testModulePresetInTheStack(): void + { + $matcher = new PresetUsageMatcher(); + $button = new PresetScope('module', 'divi/button'); + + self::assertTrue($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false)); + self::assertTrue($matcher->uses(self::page('{"modulePreset":["x","aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false)); + self::assertTrue($matcher->uses(self::page('{"modulePreset":"aaaaaaaaaa"}'), $button, 'aaaaaaaaaa', false)); + self::assertFalse($matcher->uses(self::page('{"modulePreset":["bbbbbbbbbb"]}'), $button, 'aaaaaaaaaa', false)); + self::assertFalse( + $matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), new PresetScope('module', 'divi/text'), 'aaaaaaaaaa', false), + 'an ID of another module does not count' + ); + } + + public function testDefaultModulePresetReachesModulesWithoutOwnPreset(): void + { + $matcher = new PresetUsageMatcher(); + $button = new PresetScope('module', 'divi/button'); + + self::assertTrue($matcher->uses(self::page('{}'), $button, 'dddddddddd', true)); + self::assertTrue($matcher->uses(self::page('{"modulePreset":["default"]}'), $button, 'dddddddddd', true)); + self::assertFalse($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'dddddddddd', true)); + } + + public function testGroupPresetById(): void + { + $matcher = new PresetUsageMatcher(); + $group = new PresetScope('group', 'divi/font-body'); + $attrs = '{"groupPreset":{"content.decoration.bodyFont":{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}}'; + + self::assertTrue($matcher->uses(self::page($attrs), $group, 'gggggggggg', false)); + self::assertFalse($matcher->uses(self::page($attrs), new PresetScope('group', 'divi/spacing'), 'gggggggggg', false)); + } + + public function testBrokenMarkupCountsAsUse(): void + { + self::assertTrue((new PresetUsageMatcher())->uses( + '', + new PresetScope('module', 'divi/button'), + 'aaaaaaaaaa', + false + )); + } +} +``` + +`tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`: + +```php +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)', + '%aaaaaaaaaa%', + '%gggggggggg%', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new PresetUsageQuery([ + ['scope' => new PresetScope('module', 'divi/button'), 'id' => 'aaaaaaaaaa', 'default' => false], + ['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => false], + ]); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + } + + public function testADefaultModulePresetLooksForTheModule(): 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)', + '%dddddddddd%', + '%wp:divi/button %', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new PresetUsageQuery([ + ['scope' => new PresetScope('module', 'divi/button'), 'id' => 'dddddddddd', 'default' => true], + ]); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + self::assertTrue($query->matches('')); + } + + public function testNeedsATarget(): void + { + $this->expectException(\InvalidArgumentException::class); + + new PresetUsageQuery([]); + } +} +``` + +Warum die JSON-Escape-Bedingung: Divi kann Schlüssel und Werte in Blockattributen mit `\u00XX` schreiben; eine so kodierte ID fände `LIKE` nicht (fail closed wie Ruling P23). Kandidaten ohne Treffer filtert `matches()` heraus. + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php` +Expected: FAIL, Klassen fehlen. + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Design/PresetUsageMatcher.php`: + +```php +.presetId whose groupName is the group; + * - markup the parser rejects counts as a use. + * Pure PHP. + */ +final class PresetUsageMatcher +{ + private const DEFAULT_IDS = ['', 'default', '_initial']; + + public function __construct(private Parser $parser = new Parser()) + { + } + + public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool + { + try { + $index = NodeIndex::build($this->parser->parse($content)); + } catch (MalformedMarkup) { + return true; + } + + foreach ($index->entries() as $entry) { + $block = $entry['block']; + if ($scope->kind === PresetScope::MODULE ? $this->usesModulePreset($block, $scope, $id, $isDefault) + : $this->usesGroupPreset($block->attrs(), $scope, $id)) { + return true; + } + } + + return false; + } + + 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; + } +} +``` + +`NodeSummary::presetIds()` filtert leere Strings heraus; ein `modulePreset: [""]` ergibt also `[]` und zählt für das Standard-Preset – wie in Divi. + +`src/Integrations/Divi/Design/PresetUsageQuery.php`: + +```php + */ + private array $targets; + + /** + * @param list $targets + * @throws InvalidArgumentException Without targets. + */ + public function __construct(array $targets, private PresetUsageMatcher $matcher = new PresetUsageMatcher()) + { + if ($targets === []) { + throw new InvalidArgumentException('PresetUsageQuery needs at least one preset.'); + } + $this->targets = array_values($targets); + } + + public function candidateCondition(\wpdb $db): string + { + $likes = []; + foreach ($this->targets as $target) { + $likes[] = '%' . $db->esc_like($target['id']) . '%'; + if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) { + $likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%'; + } + } + $likes = array_values(array_unique($likes)); + + return (string) $db->prepare( + '(' . str_repeat('post_content LIKE %s OR ', count($likes)) . 'post_content REGEXP %s)', + ...array_merge($likes, [self::ESCAPED_LETTER]) + ); + } + + public function matches(string $content): bool + { + foreach ($this->targets as $target) { + if ($this->matcher->uses($content, $target['scope'], $target['id'], $target['default'])) { + return true; + } + } + + return false; + } +} +``` + +`'\\\\u00[4-7][0-9a-fA-F]'` in einfachen Anführungszeichen ergibt die Zeichenfolge `\\u00[4-7][0-9a-fA-F]` – dieselbe wie `str_repeat(chr(92), 2) . 'u00…'` in `LibraryUsageQuery` (MySQL-REGEXP sieht nach dem Entschärfen `\u00…`). + +Ein Modul ohne Attribute steht als `` im Inhalt (ohne Leerzeichen-JSON); die Kandidatenbedingung `wp:divi/button ` (mit Leerzeichen) trifft beide Schreibweisen, weil auf den Namen immer ein Leerzeichen folgt. + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/PresetUsageMatcher.php src/Integrations/Divi/Design/PresetUsageQuery.php tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php +git commit -m "feat: Fundstellen eines Presets (PresetUsageMatcher, PresetUsageQuery)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 8: Änderungs-Pipeline – `DesignArea`, `DesignPlan`, `DesignChange`, `PresetArea` + +**Files:** +- Create: `src/Integrations/Divi/Design/DesignArea.php` +- Create: `src/Integrations/Divi/Design/DesignPlan.php` +- Create: `src/Integrations/Divi/Design/DesignChange.php` +- Create: `src/Integrations/Divi/Design/PresetArea.php` +- Test: `tests/Unit/Integrations/Divi/Design/DesignChangeTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetAreaTest.php` + +**Interfaces:** +- Consumes: `SnapshotStore`, `SnapshotTooLarge` (Task 4), `DesignSystemWriter::savePresets()`/`presets()` (Task 5), `PresetBook`, `PresetDiff` (Tasks 2–3), `UsageReport` (vorhanden), `ChangeLog::record()/recordPartial()`, `Errors`, `ToolResult`, `Rollback`, `WriteFailed`, `ReleaseGate::assertWritable()`, `DiviLocked::in()`. +- Produces: + - `interface DesignArea { public function name(): string; public function label(): string; public function raw(): array; public function rev(array $raw): string; public function write(array $raw, bool $mayRemove): array; public function verify(array $expected, array $actual): bool; }` + - `final class DesignPlan { public function __construct(public readonly array $raw, public readonly array $diff, public readonly string $summary, public readonly ?UsageReport $usages = null, public readonly array $warnings = [], public readonly bool $mayRemove = false); public function isEmpty(): bool }` + - `final class DesignChange { public function __construct(SnapshotStore $snapshots, ChangeLog $log); public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error; public static function inputProperties(): array; public static function outputSchema(): array }` – `$plan` ist `callable(array $currentRaw): DesignPlan|WP_Error`. + - `final class PresetArea implements DesignArea { public function __construct(DesignSystemWriter $writer) }`. + +Antworten von `run()`: +- Plan liefert `WP_Error` → unverändert zurück. +- Leerer Plan → `ToolResult` „Keine Änderung …“ mit `diff: []`, `design_rev`. +- Ohne `confirm: true` → `Errors::confirmRequired($summary . ' Nichts wurde gespeichert (Probelauf).', ['diff' => …, 'design_rev' => …] + affected-Liste + ggf. `blocked_ids`)`. +- `confirm` ohne/mit falscher `design_rev` → `Errors::revMismatch( wurde seit dem Probelauf geändert …)`. +- Fundstellen blockieren (Live-Seite ohne `edit_post`, über `HARD_CAP`) → `forbidden` mit `blocked_ids`. +- Schreiben → `ToolResult` mit `snapshot_id`, `log_id`, `diff`, `design_rev` (nach dem Schreiben), `warnings`. + +- [ ] **Step 1: Failing test für `DesignChange` schreiben** + +```php +stored; + } + + public function rev(array $raw): string + { + return substr(md5((string) json_encode($raw)), 0, 12); + } + + public function write(array $raw, bool $mayRemove): array + { + $this->writes[] = [$raw, $mayRemove]; + if ($this->failWrites > 0) { + --$this->failWrites; + throw new RuntimeException('divi refused'); + } + $this->stored = $raw; + + return $raw; + } + + public function verify(array $expected, array $actual): bool + { + return $this->verify; + } + }; + } + + private static function plan(array $raw, ?UsageReport $usages = null): callable + { + return static fn (array $current): DesignPlan => new DesignPlan( + $raw, + [['kind' => 'module', 'scope' => 'divi/button', 'id' => 'a', 'name' => 'CTA', 'action' => 'added']], + 'Preset „CTA“ wird angelegt.', + $usages + ); + } + + private static function usages(array $blocked = []): UsageReport + { + return new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, 1, $blocked, 0, false); + } + + public function testDryRunListsDiffAffectedAndRevAndSavesNothing(): void + { + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('save'); + + $result = (new DesignChange($snapshots, FakeChangeLog::forbidden())) + ->run('lit/divi-presets-set', $area, [], self::plan(['v' => 2], self::usages())); + + $details = self::assertToolError($result, 'confirm_required'); + self::assertStringContainsString('Preset „CTA“ wird angelegt. Nichts wurde gespeichert (Probelauf).', $result->get_error_message()); + self::assertSame('added', $details['diff'][0]['action']); + self::assertSame($area->rev(['v' => 1]), $details['design_rev']); + self::assertSame(3, $details['affected'][0]['post_id']); + self::assertSame(1, $details['affected_count']); + self::assertSame([], $area->writes); + } + + public function testNothingToDoIsNoDryRun(): void + { + $stored = ['v' => 1]; + $empty = static fn (array $current): DesignPlan => new DesignPlan($current, [], 'Nichts.'); + + $result = (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden())) + ->run('lit/divi-presets-set', self::area($stored), [], $empty); + + self::assertTrue($result['ok']); + self::assertSame([], $result['diff']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + } + + public function testStaleDesignRevWritesNothing(): void + { + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('save'); + + $result = (new DesignChange($snapshots, FakeChangeLog::forbidden())) + ->run('lit/divi-presets-set', $area, ['confirm' => true, 'design_rev' => 'veraltet'], self::plan(['v' => 2])); + + self::assertToolError($result, 'rev_mismatch'); + self::assertStringContainsString('Presets wurden seit dem Probelauf geändert', $result->get_error_message()); + self::assertSame([], $area->writes); + } + + public function testBlockedUsagesRefuseTheConfirm(): void + { + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('save'); + + $result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2], self::usages([3])) + ); + + $details = self::assertToolError($result, 'forbidden'); + self::assertSame([3], $details['blocked_ids']); + } + + public function testConfirmTakesASnapshotWritesAndLogs(): void + { + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->with('presets', ['v' => 1], 'lit/divi-presets-set')->andReturn('presets-1'); + $snapshots->expects('attachLog')->with('presets', 'presets-1', 9); + $log = new FakeChangeLog(9); + + $result = (new DesignChange($snapshots, $log))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev(['v' => 1])], + self::plan(['v' => 2]) + ); + + self::assertTrue($result['ok']); + self::assertSame('presets-1', $result['snapshot_id']); + self::assertSame(9, $result['log_id']); + self::assertSame($area->rev(['v' => 2]), $result['design_rev']); + self::assertSame(['v' => 2], $stored); + self::assertSame('presets', $log->records[0]['object_id']); + self::assertSame('divi_design', $log->records[0]['object_type']); + self::assertSame('presets-1', $log->records[0]['snapshot_id']); + } + + public function testLockedDiviWritesNoSnapshot(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0')); + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('save'); + + $this->expectException(DiviLocked::class); + + (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + } + + public function testTooLargeSnapshotRefusesTheChange(): void + { + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andThrow(new SnapshotTooLarge(3000000)); + + $result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + + self::assertToolError($result, 'snapshot_too_large'); + self::assertSame([], $area->writes); + } + + public function testFailedWriteRestoresTheSnapshot(): void + { + $stored = ['v' => 1]; + $area = self::area($stored, 1); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-1'); + + try { + (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('presets-1', $e->details['snapshot_id']); + } + + self::assertSame([[['v' => 2], false], [['v' => 1], true]], $area->writes); + self::assertSame(['v' => 1], $stored); + } + + public function testReadBackMismatchRestoresTheSnapshot(): void + { + $stored = ['v' => 1]; + $area = self::area($stored, 0, false); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-1'); + + $this->expectException(WriteFailed::class); + + (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + } + + public function testFailedRollbackIsPartial(): void + { + $stored = ['v' => 1]; + $area = self::area($stored, 2); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-1'); + $log = FakeChangeLog::partialsOnly(); + + try { + (new DesignChange($snapshots, $log))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertStringContainsString('presets-1', (string) $e->hint); + } + + self::assertCount(1, $log->partials); + } +} +``` + +`assertToolError()` (Trait `AssertsToolErrors`) gibt die Fehlerdaten zurück, wie der Client sie sieht (JSON der Meldung inklusive aller Details). + +- [ ] **Step 2: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignChangeTest.php` +Expected: FAIL, Klassen fehlen. + +- [ ] **Step 3: `DesignArea` und `DesignPlan` implementieren** + +```php + */ + public function raw(): array; + + /** @param array $raw */ + public function rev(array $raw): string; + + /** + * @param array $raw + * @return array The inventory as read back after writing. + */ + public function write(array $raw, bool $mayRemove): array; + + /** + * Whether Divi stored what was written – sanitizing may change values, never which entries exist. + * + * @param array $expected + * @param array $actual + */ + public function verify(array $expected, array $actual): bool; +} +``` + +```php + $raw + * @param list> $diff + * @param list $warnings + */ + public function __construct( + public readonly array $raw, + public readonly array $diff, + public readonly string $summary, + public readonly ?UsageReport $usages = null, + public readonly array $warnings = [], + public readonly bool $mayRemove = false + ) { + } + + public function isEmpty(): bool + { + return $this->diff === []; + } +} +``` + +- [ ] **Step 4: `DesignChange` implementieren** + +```php +> Input properties every design write tool takes. */ + public static function inputProperties(): array + { + return [ + 'confirm' => ['type' => 'boolean', 'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.'], + 'design_rev' => ['type' => 'string', 'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).'], + ]; + } + + /** @return array */ + public static function outputSchema(): array + { + return ToolResult::schema([ + 'diff' => ['type' => 'array', 'items' => ['type' => 'object']], + 'design_rev' => ['type' => 'string'], + ], ['diff', 'design_rev']); + } + + /** + * @param array $input The tool input (confirm, design_rev). + * @param callable(array): (DesignPlan|WP_Error) $plan + * @return array|WP_Error + * @throws WriteFailed + * @throws DiviLocked + */ + public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error + { + $current = $area->raw(); + $rev = $area->rev($current); + $planned = $plan($current); + if ($planned instanceof WP_Error) { + return $planned; + } + + if ($planned->isEmpty()) { + return ToolResult::ok('Keine Änderung – ' . $planned->summary) + ->with('diff', []) + ->with('design_rev', $rev) + ->warnings($planned->warnings) + ->toArray(); + } + + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + $planned->summary . ' Nichts wurde gespeichert (Probelauf).', + ['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings] + + ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0]) + + ($planned->usages !== null && $planned->usages->blocked !== [] + ? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : []) + ); + } + + if (($input['design_rev'] ?? null) !== $rev) { + return Errors::revMismatch( + sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()), + 'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen ' + . 'design_rev bestätigen.' + ); + } + + $blocked = $this->blocked($planned); + if ($blocked !== null) { + return $blocked; + } + + ReleaseGate::assertWritable(); + try { + $snapshotId = $this->snapshots->save($area->name(), $current, $ability); + } catch (SnapshotTooLarge $e) { + return Errors::make( + 'snapshot_too_large', + sprintf( + 'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.', + $area->label(), + $e->size, + SnapshotStore::MAX_BYTES + ), + 'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte ' + . 'Einträge dort entfernen.', + 413 + ); + } + + $saved = $this->write($ability, $area, $planned, $current, $snapshotId); + $logId = $this->log->record( + $ability, + self::OBJECT_TYPE, + $area->name(), + $planned->summary, + snapshotId: $snapshotId + ); + $this->snapshots->attachLog($area->name(), $snapshotId, $logId); + + return ToolResult::ok($planned->summary) + ->snapshotId($snapshotId) + ->logId($logId) + ->warnings($planned->warnings) + ->with('diff', $planned->diff) + ->with('design_rev', $area->rev($saved)) + ->toArray(); + } + + /** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */ + private function blocked(DesignPlan $plan): ?WP_Error + { + $usages = $plan->usages; + if ($usages === null || ! $usages->blocks()) { + return null; + } + + return Errors::make( + 'forbidden', + $usages->tooMany + ? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde ' + . 'gespeichert.' + : 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde ' + . 'gespeichert.', + 'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.', + 403, + ['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)] + + ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : []) + ); + } + + /** + * @param array $current + * @return array + * @throws WriteFailed + */ + private function write(string $ability, DesignArea $area, DesignPlan $plan, array $current, string $snapshotId): array + { + try { + $saved = $area->write($plan->raw, $plan->mayRemove); + if (! $area->verify($plan->raw, $saved)) { + throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name())); + } + + return $saved; + } catch (Throwable $e) { + if (DiviLocked::in($e) !== null) { + throw $e; + } + $this->rollBack($ability, $area, $current, $snapshotId, $e); + } + } + + /** + * @param array $current + * @throws WriteFailed Always. + */ + private function rollBack(string $ability, DesignArea $area, array $current, string $snapshotId, Throwable $cause): never + { + $reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label()); + $failures = Rollback::attempt(static fn () => $area->write($current, true)); + if ($failures === []) { + throw WriteFailed::rolledBack( + $reason, + $cause, + sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()), + details: ['snapshot_id' => $snapshotId] + ); + } + + $this->log->recordPartial( + $ability, + self::OBJECT_TYPE, + $area->name(), + sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId) + ); + throw WriteFailed::partial( + $reason, + $cause, + sprintf('design %s rollback failed: %s', $area->name(), $failures[0]), + state: sprintf('Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).', $area->label(), $snapshotId), + hint: sprintf( + 'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann ' + . 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.', + $snapshotId + ), + details: ['snapshot_id' => $snapshotId] + ); + } +} +``` + +Zeilen über 120 Zeichen meldet phpcs; sie wie im übrigen Code umbrechen (Argumente je Zeile). + +- [ ] **Step 5: Test laufen lassen – er besteht** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignChangeTest.php` +Expected: PASS. + +- [ ] **Step 6: Failing test für `PresetArea` schreiben** + +```php +allows('presets')->andReturn($stored); + $writer->expects('savePresets') + ->with(PresetBook::fromDivi($stored)->toRest(), true) + ->andReturn($stored); + $area = new PresetArea($writer); + + self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]); + self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw()); + self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored)); + self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true)); + } + + public function testVerifyIgnoresSanitizedValuesButNotMissingPresets(): void + { + $area = new PresetArea(Mockery::mock(DesignSystemWriter::class)); + $expected = PresetBookTest::stored(); + $sanitized = $expected; + $sanitized['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 'bereinigt']]; + $missing = $expected; + unset($missing['module']['divi/button']['items']['bbbbbbbbbb']); + $otherDefault = $expected; + $otherDefault['module']['divi/text']['default'] = ''; + + self::assertTrue($area->verify($expected, $sanitized)); + self::assertFalse($area->verify($expected, $missing)); + self::assertFalse($area->verify($expected, $otherDefault)); + } +} +``` + +- [ ] **Step 7: `PresetArea` implementieren** + +```php +writer->presets())->data(); + } + + public function rev(array $raw): string + { + return PresetBook::fromDivi($raw)->rev(); + } + + public function write(array $raw, bool $mayRemove): array + { + return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove)) + ->data(); + } + + public function verify(array $expected, array $actual): bool + { + $structural = array_filter( + PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)), + static fn (array $row): bool => $row['action'] !== 'changed' + ); + + return $structural === []; + } +} +``` + +- [ ] **Step 8: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design` +Expected: PASS. + +- [ ] **Step 9: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/DesignArea.php src/Integrations/Divi/Design/DesignPlan.php src/Integrations/Divi/Design/DesignChange.php src/Integrations/Divi/Design/PresetArea.php tests/Unit/Integrations/Divi/Design/DesignChangeTest.php tests/Unit/Integrations/Divi/Design/PresetAreaTest.php +git commit -m "feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 9: `PresetComposer` – Preset-Eintrag aus `set`, `attrs`, `from_node` und `group_presets` + +**Files:** +- Create: `src/Integrations/Divi/Design/PresetComposer.php` +- Modify: `src/Integrations/Divi/Design/DesignSystemWriter.php` (neue Methode `groupsOf()`, Manifest) +- Modify: `tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php` (Manifest-Liste) +- Test: `tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` + +**Interfaces:** +- Consumes: `Composer::update(Block, array, string)` (vorhanden; Rückgabe `{attrs, errors, invalid_paths}`), `StoredNodeGuard::problems(Block)` (vorhanden), `Block::create()`, `AttrMap::flatten()`, `PresetBook`, `PresetScope` (Task 2). +- Produces: + - `DesignSystemWriter::groupsOf(string $moduleName): array` – `array` Attributpfad der Gruppe (`groupId`) → Gruppenname (`divi/spacing`), aus `GlobalPreset::get_group_preset_default_attr(ModuleRegistration::get_module_settings($moduleName))`; `[]` ohne Divi. + - `final class PresetComposer { public function __construct(Composer $composer, StoredNodeGuard $guard, Closure $presetAttrs, Closure $groupsOf, string $version, Closure $now); public function compose(PresetBook $book, PresetScope $scope, ?array $existing, array $input, string $field, ?Block $source): array }` – Rückgabe `array{item: array, errors: array, invalid_paths: list}`; `$presetAttrs` = `DesignSystemWriter::presetAttrs(...)`, `$groupsOf` = `DesignSystemWriter::groupsOf(...)`, `$now` liefert Millisekunden. + +Regeln von `compose()` (Spec §6.3): +- Modul des Presets: Modul-Preset → `$scope->name`; Gruppen-Preset → `existing.moduleName`, sonst Modul von `$source`, sonst `divi/` aus der Eingabe; fehlt alles → Fehler `.source_module`. +- Gruppe (`groupId`) eines neuen Gruppen-Presets: der eine Pfad aus `groupsOf()` mit Gruppenname `$scope->name`; mehrere → Eingabe `group_id` nötig; keiner → Fehler. +- Ausgangswerte: `existing.attrs` bzw. `[]`; mit `$source`: zuerst `StoredNodeGuard::problems($source)` (Fehler `.from_node`), dann die Preset-Attribute des Knotens (`presetAttrs(, $source->attrs())['all']`, bei Gruppen nur unterhalb `groupId`) über die Ausgangswerte gelegt. +- `set`/`attrs` laufen durch `Composer::update()` an `Block::create(, )`. +- Danach muss jeder Pfad Preset-Attribut sein (`presetAttrs(...)['all']` enthält ihn) – sonst `.attrs.`: „kein Preset-Attribut (Inhalte wie Texte, Links und Bilder gehören ins Modul)“; bei Gruppen zusätzlich unterhalb `groupId` – sonst „gehört nicht zur Gruppe “. +- `group_presets` (nur Modul-Presets): `{ "": "" | null }`, Gruppe als Kurzname (`font`), voller Name (`divi/font`) oder Pfad (`button.decoration.font`); Auflösung über `groupsOf()`, Preset über `PresetBook::find()` im Bereich `group`. +- Eintrag: `type`, `id` (bestehend oder `PresetBook::newId()`), `name` (`rename` vor `name` vor bestehend), bei Gruppen `groupName`/`groupId`, `moduleName`, `version`, `created` (bestehend oder jetzt), `updated` (jetzt), `attrs`, `styleAttrs`/`renderAttrs` (nur wenn nicht leer), `groupPresets` (nur wenn nicht leer), `priority`/`order` vom bestehenden Eintrag. + +- [ ] **Step 1: `groupsOf()` im Writer – Test ergänzen** + +In `DesignSystemWriterTest::testIsARegisteredDesignAdapterWithTheSyncMirror` die erwartete Liste um zwei Einträge ergänzen (nach `'Conversion::get_preset_attrs()',`): + +```php + 'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()', +``` + +und einen Test anhängen: + +```php + public function testWithoutDiviAModuleHasNoGroups(): void + { + self::assertSame([], (new DesignSystemWriter())->groupsOf('divi/button')); + } +``` + +- [ ] **Step 2: `groupsOf()` implementieren** + +In `DesignSystemWriter`: Import `use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;`, im Manifest nach `Conversion::get_preset_attrs` ergänzen: + +```php + new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1), + new Call(ModuleRegistration::class . '::get_module_settings', 1), +``` + +und die Methode: + +```php + /** + * The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g. + * "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module. + * + * @return array + */ + public function groupsOf(string $moduleName): array + { + if (! class_exists(ModuleRegistration::class)) { + return []; + } + + $settings = ModuleRegistration::get_module_settings($moduleName); + if ($settings === null) { + return []; + } + + $groups = []; + foreach (GlobalPreset::get_group_preset_default_attr($settings) as $groupId => $group) { + if (is_array($group) && is_string($group['groupName'] ?? null)) { + $groups[(string) $groupId] = $group['groupName']; + } + } + + return $groups; + } +``` + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php tests/Unit/Integrations/Divi/Release` +Expected: PASS. + +- [ ] **Step 3: Failing test für `PresetComposer` schreiben** + +```php + $value) { + if (is_array($value)) { + $kept = array_diff_key($value, ['innerContent' => true]); + if ($kept !== []) { + $all[$key] = $kept; + } + } + } + + return ['all' => $all, 'style' => $all, 'render' => []]; + }; + } + + private static function composer(bool $unfiltered = true): PresetComposer + { + return new PresetComposer( + ComposeFixture::composer($unfiltered), + new StoredNodeGuard( + ComposeFixture::cleaner($unfiltered), + ComposeFixture::references(), + ComposeFixture::sanitizer($unfiltered) + ), + self::presetAttrs(), + static fn (string $module): array => [ + 'module.decoration.spacing' => 'divi/spacing', + 'button.decoration.font' => 'divi/font', + ], + '5.15.1', + static fn (): int => self::NOW + ); + } + + private static function book(): PresetBook + { + return PresetBook::fromDivi(PresetBookTest::stored()); + } + + public function testNewModulePresetFromAttrs(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'CTA sekundär', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#123456']]], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + $item = $result['item']; + self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', $item['id']); + self::assertSame(['module', 'CTA sekundär', 'divi/button', '5.15.1'], [$item['type'], $item['name'], + $item['moduleName'], $item['version']]); + self::assertSame([self::NOW, self::NOW], [$item['created'], $item['updated']]); + self::assertSame('#123456', $item['attrs']['button']['decoration']['background']['desktop']['value']['color']); + self::assertSame($item['attrs'], $item['styleAttrs']); + self::assertArrayNotHasKey('renderAttrs', $item); + } + + public function testContentPathsAreRefused(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit Text', + 'set' => ['text' => 'Jetzt buchen'], + ], 'items[0]', null); + + self::assertArrayHasKey('items[0].attrs.button.innerContent.desktop.value.text', $result['errors']); + self::assertStringContainsString('kein Preset-Attribut', $result['errors']['items[0].attrs.button.innerContent.desktop.value.text']); + } + + public function testChangingMergesKeepsIdCreatedAndOrder(): void + { + $existing = self::book()->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa']; + $existing['created'] = 5; + $existing['order'] = 3; + $existing['attrs'] = ['button' => ['decoration' => ['border' => ['desktop' => ['value' => ['radius' => '4px']]]]]]; + + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), $existing, [ + 'rename' => 'CTA Primär', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#000000']]], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + self::assertSame(['aaaaaaaaaa', 'CTA Primär', 5, self::NOW, 3], [$result['item']['id'], $result['item']['name'], + $result['item']['created'], $result['item']['updated'], $result['item']['order']]); + self::assertSame('4px', $result['item']['attrs']['button']['decoration']['border']['desktop']['value']['radius']); + } + + public function testFromNodeTakesTheDesignButNotTheContent(): void + { + $node = Block::create('divi/button', [ + 'button' => [ + 'innerContent' => ['desktop' => ['value' => ['text' => 'Hallo']]], + 'decoration' => ['background' => ['desktop' => ['value' => ['color' => '#abcdef']]]], + ], + ]); + + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Aus Seite', + ], 'items[0]', $node); + + self::assertSame([], $result['errors']); + self::assertSame('#abcdef', $result['item']['attrs']['button']['decoration']['background']['desktop']['value']['color']); + self::assertArrayNotHasKey('innerContent', $result['item']['attrs']['button']); + } + + public function testEditorCannotCarryBuilderOnlyValuesFromANode(): void + { + $node = Block::create('divi/button', [ + 'css' => ['desktop' => ['value' => ['mainElement' => 'color:red']]], + 'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#000000']]]]], + ]); + + $result = self::composer(false)->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit CSS', + ], 'items[0]', $node); + + self::assertArrayHasKey('items[0].from_node', $result['errors']); + } + + public function testGroupPresetOnlyTakesItsGroup(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('group', 'divi/spacing'), null, [ + 'name' => 'Abstand groß', + 'source_module' => 'button', + 'attrs' => [ + 'module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '40px']]], + 'button.decoration.background' => ['desktop' => ['color' => '#000000']], + ], + ], 'items[0]', null); + + self::assertSame( + 'gehört nicht zur Gruppe module.decoration.spacing', + $result['errors']['items[0].attrs.button.decoration.background.desktop.value.color'] + ); + self::assertSame(['group', 'divi/spacing', 'module.decoration.spacing', 'divi/button'], [ + $result['item']['type'], $result['item']['groupName'], $result['item']['groupId'], + $result['item']['moduleName'], + ]); + } + + public function testNestedGroupPresetsByShortName(): void + { + $book = self::book()->with(new PresetScope('group', 'divi/font'), [ + 'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Button-Schrift', 'groupName' => 'divi/font', + 'groupId' => 'button.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [], + ]); + + $result = self::composer()->compose($book, new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit Schrift', + 'group_presets' => ['font' => 'Button-Schrift'], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']], + $result['item']['groupPresets'] + ); + } + + public function testUnknownGroupAndPresetAreErrors(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'X', + 'group_presets' => ['sizing' => 'Gibt es nicht', 'font' => 'Fehlt'], + ], 'items[0]', null); + + self::assertStringContainsString('keine Gruppe', $result['errors']['items[0].group_presets.sizing']); + self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']); + } + + public function testNewPresetNeedsAName(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [], + 'items[0]', null); + + self::assertSame('Name des neuen Presets fehlt', $result['errors']['items[0].name']); + } +} +``` + +Hinweis zu den Fehlerschlüsseln: `PresetComposer` meldet unerlaubte Pfade auf **Blattebene** (`AttrMap::flatten()`-Schlüssel) mit dem Präfix `.attrs.`. `Composer::update()` speichert die Kurzform `{"desktop": {"color": …}}` als `desktop.value.color`; daher die Schlüssel im Test. + +`StoredNodeGuard` sperrt für Benutzer ohne `unfiltered_html` jeden gespeicherten Pfad, der `ValueCleaner::BUILDER_ONLY` trifft (`css`, `css.*`, `*.css.*`, `decoration.attributes` …) – unabhängig vom Katalog; der Knoten im Test trägt `css.desktop.value.mainElement`. + +- [ ] **Step 4: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` +Expected: FAIL, Klasse fehlt. + +- [ ] **Step 5: Implementieren** + +```php +): array{all: array, style: array, + * render: array} $presetAttrs DesignSystemWriter::presetAttrs() + * @param Closure(string): array $groupsOf DesignSystemWriter::groupsOf() + * @param Closure(): int $now Milliseconds, like the builder's Date.now(). + */ + public function __construct( + private Composer $composer, + private StoredNodeGuard $guard, + private Closure $presetAttrs, + private Closure $groupsOf, + private string $version, + private Closure $now + ) { + } + + /** + * @param array|null $existing The preset to change, null for a new one. + * @param array $input One item of lit/divi-presets-set. + * @return array{item: array, errors: array, invalid_paths: list} + */ + public function compose( + PresetBook $book, + PresetScope $scope, + ?array $existing, + array $input, + string $field, + ?Block $source + ): array { + $errors = []; + $module = $this->moduleOf($scope, $existing, $input, $source); + if ($module === null) { + $errors[$field . '.source_module'] = 'Modul des Gruppen-Presets fehlt (source_module oder from_node)'; + + return ['item' => [], 'errors' => $errors, 'invalid_paths' => []]; + } + + $groupId = null; + if ($scope->kind === PresetScope::GROUP) { + $groupId = $this->groupIdOf($scope, $module, $existing, $input, $field, $errors); + if ($groupId === null) { + return ['item' => [], 'errors' => $errors, 'invalid_paths' => []]; + } + } + + $attrs = is_array($existing['attrs'] ?? null) ? $existing['attrs'] : []; + if ($source !== null) { + $problems = $this->guard->problems($source); + if ($problems !== []) { + $errors[$field . '.from_node'] = implode('; ', $problems); + } + $taken = ($this->presetAttrs)($module, $source->attrs())['all']; + $attrs = array_replace_recursive($attrs, $groupId === null ? $taken : self::below($taken, $groupId)); + } + + $change = array_intersect_key($input, ['set' => true, 'attrs' => true]); + if ($change !== []) { + $updated = $this->composer->update(Block::create($module, $attrs), $change, $field); + $attrs = $updated['attrs']; + $errors += $updated['errors']; + if ($updated['invalid_paths'] !== []) { + return ['item' => [], 'errors' => $errors, 'invalid_paths' => $updated['invalid_paths']]; + } + } + + $split = ($this->presetAttrs)($module, $attrs); + $allowed = AttrMap::flatten($split['all']); + foreach (array_keys(AttrMap::flatten($attrs)) as $path) { + if (! array_key_exists($path, $allowed)) { + $errors[$field . '.attrs.' . $path] = self::CONTENT; + } elseif ($groupId !== null && ! str_starts_with($path, $groupId . '.')) { + $errors[$field . '.attrs.' . $path] = 'gehört nicht zur Gruppe ' . $groupId; + } + } + + $nested = $this->groupPresets($book, $scope, $module, $existing, $input, $field, $errors); + $name = $this->nameOf($existing, $input, $field, $errors); + + return [ + 'item' => $this->item($scope, $existing, $name, $module, $groupId, $attrs, $split, $nested), + 'errors' => $errors, + 'invalid_paths' => [], + ]; + } + + /** + * @param array|null $existing + * @param array $input + */ + private function moduleOf(PresetScope $scope, ?array $existing, array $input, ?Block $source): ?string + { + if ($scope->kind === PresetScope::MODULE) { + return $scope->name; + } + if (is_string($existing['moduleName'] ?? null)) { + return $existing['moduleName']; + } + if ($source !== null) { + return $source->name(); + } + $short = $input['source_module'] ?? null; + if (! is_string($short) || preg_match('/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/D', $short) !== 1) { + return null; + } + + return str_contains($short, '/') ? $short : 'divi/' . $short; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + */ + private function groupIdOf( + PresetScope $scope, + string $module, + ?array $existing, + array $input, + string $field, + array &$errors + ): ?string { + if (is_string($existing['groupId'] ?? null)) { + return $existing['groupId']; + } + + $paths = array_keys(array_filter( + ($this->groupsOf)($module), + static fn (string $group): bool => $group === $scope->name + )); + $wanted = $input['group_id'] ?? null; + if (is_string($wanted) && in_array($wanted, $paths, true)) { + return $wanted; + } + if (count($paths) === 1 && $wanted === null) { + return (string) $paths[0]; + } + + $errors[$field . '.group_id'] = $paths === [] + ? sprintf('%s hat keine Gruppe %s', $module, $scope->name) + : sprintf('mehrere Gruppen %s in %s – group_id angeben: %s', $scope->name, $module, implode(', ', $paths)); + + return null; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + * @return array, groupName: string}> + */ + private function groupPresets( + PresetBook $book, + PresetScope $scope, + string $module, + ?array $existing, + array $input, + string $field, + array &$errors + ): array { + $nested = is_array($existing['groupPresets'] ?? null) ? $existing['groupPresets'] : []; + $wanted = $input['group_presets'] ?? null; + if ($wanted === null) { + return $nested; + } + if ($scope->kind !== PresetScope::MODULE || ! is_array($wanted)) { + $errors[$field . '.group_presets'] = 'nur bei Modul-Presets, als Objekt Gruppe → Preset'; + + return $nested; + } + + $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); + continue; + } + if ($preset === null) { + unset($nested[$groupId]); + continue; + } + try { + $found = is_string($preset) ? $book->find(new PresetScope(PresetScope::GROUP, $groups[$groupId]), $preset) + : null; + } catch (InvalidArgumentException $e) { + $errors[$path] = $e->getMessage(); + continue; + } + if ($found === null) { + $errors[$path] = sprintf('unbekanntes Gruppen-Preset "%s" (%s)', (string) $preset, $groups[$groupId]); + continue; + } + $nested[$groupId] = ['presetId' => [(string) $found['id']], 'groupName' => $groups[$groupId]]; + } + + 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; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + */ + private function nameOf(?array $existing, array $input, string $field, array &$errors): string + { + foreach (['rename', 'name'] as $key) { + if (is_string($input[$key] ?? null) && trim($input[$key]) !== '') { + return trim($input[$key]); + } + } + if (is_string($existing['name'] ?? null)) { + return $existing['name']; + } + $errors[$field . '.name'] = 'Name des neuen Presets fehlt'; + + return ''; + } + + /** + * @param array|null $existing + * @param array $attrs + * @param array{all: array, style: array, render: array} $split + * @param array $nested + * @return array + */ + private function item( + PresetScope $scope, + ?array $existing, + string $name, + string $module, + ?string $groupId, + array $attrs, + array $split, + array $nested + ): array { + $now = ($this->now)(); + $item = ['type' => $scope->kind, 'id' => (string) ($existing['id'] ?? PresetBook::newId()), 'name' => $name]; + if ($groupId !== null) { + $item += ['groupName' => $scope->name, 'groupId' => $groupId]; + } + $item += [ + 'moduleName' => $module, + 'version' => $this->version, + 'created' => (int) ($existing['created'] ?? $now), + 'updated' => $now, + 'attrs' => $attrs, + ]; + if ($split['style'] !== []) { + $item['styleAttrs'] = $split['style']; + } + if ($split['render'] !== []) { + $item['renderAttrs'] = $split['render']; + } + if ($nested !== []) { + $item['groupPresets'] = $nested; + } + foreach (['priority', 'order'] as $key) { + if (isset($existing[$key])) { + $item[$key] = $existing[$key]; + } + } + + return $item; + } + + /** + * The part of $attrs below the dotted path $groupId. + * + * @param array $attrs + * @return array + */ + private static function below(array $attrs, string $groupId): array + { + $value = AttrMap::get($attrs, $groupId); + + return $value === null ? [] : AttrMap::set([], $groupId, $value); + } +} +``` + +`AttrMap::get()` liest einen Punkt-Pfad (`null`, wenn er fehlt), `AttrMap::set()` legt fehlende Zwischenstufen an. + +- [ ] **Step 6: Test laufen lassen – er besteht** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` +Expected: PASS. + +- [ ] **Step 7: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/PresetComposer.php src/Integrations/Divi/Design/DesignSystemWriter.php tests/Unit/Integrations/Divi/Design/PresetComposerTest.php tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php +git commit -m "feat: PresetComposer – Preset aus Kurz-/Vollform, Modul und Gruppen-Presets" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: `lit/divi-preset-get` – ein Preset mit Einstellungen und Fundstellen lesen + +**Files:** +- Create: `src/Integrations/Divi/Design/DiviPresetGetAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` (Writer-Instanz, Tool registrieren) +- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Namensliste) +- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php` + +**Interfaces:** +- Consumes: `DesignSystemWriter::presets()`, `PresetBook`, `PresetScope`, `PresetUsageQuery`, `GlobalUsages::reportFor()`, `UsageReport::listing()`, `ShortformMap::values()`, `AttrMap::flatten()`, `JsonMap::of()`, `Annotations::read()`, `Errors`. +- Produces: `final class DiviPresetGetAbility implements Ability { public function __construct(DesignSystemWriter $writer, GlobalUsages $usages) }`; Name `lit/divi-preset-get`; Ausgabe `{id, name, kind, scope, default, module_name, group_id, set, attrs, group_presets, usage, design_rev}`. + +- [ ] **Step 1: Failing test schreiben** + +```php +allows('presets')->andReturn(PresetBookTest::stored()); + $usages ??= Mockery::mock(GlobalUsages::class); + + return new DiviPresetGetAbility($writer, $usages); + } + + public function testReadsAPresetWithItsUsage(): void + { + Functions\when('current_user_can')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->with(Mockery::type(PresetUsageQuery::class), false) + ->andReturn(new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', + 'type' => 'page']], 0, 1, [], 0, false)); + $ability = $this->ability($usages); + + self::assertTrue($ability->permission(['module' => 'text', 'preset' => 'Fließtext'])); + $result = $ability->execute(['module' => 'text', 'preset' => 'Fließtext']); + + self::assertSame(['dddddddddd', 'Fließtext', 'module', 'divi/text', true], [$result['id'], $result['name'], + $result['kind'], $result['scope'], $result['default']]); + self::assertSame([[ + 'group_id' => 'content.decoration.bodyFont', + 'group_name' => 'divi/font-body', + 'presets' => [['id' => 'gggggggggg', 'name' => 'Lesetext']], + ]], $result['group_presets']); + self::assertSame(3, $result['usage']['affected'][0]['post_id']); + self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $result['design_rev']); + self::assertFalse($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testFlattensAttrsLikeNodeGet(): void + { + Functions\when('current_user_can')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); + + $result = $this->ability($usages)->execute(['module' => 'button', 'preset' => 'aaaaaaaaaa']); + + self::assertSame(['button.x' => 1], (array) $result['attrs']); + } + + public function testUnknownAndAmbiguousPresets(): void + { + Functions\when('current_user_can')->justReturn(true); + + self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Fehlt']), 'not_found'); + $ambiguous = self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Doppelt']), + 'validation_failed'); + self::assertStringContainsString('bbbbbbbbbb, cccccccccc', $ambiguous['fields']['preset']); + self::assertToolError($this->ability()->execute(['preset' => 'x']), 'validation_failed'); + } + + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertToolError($this->ability()->permission([]), 'forbidden'); + } +} +``` + +`AttrMap::flatten()` liefert für `['button' => ['x' => 1]]` den Schlüssel `button.x` (Blätter); `JsonMap::of()` gibt ein `stdClass` zurück, daher der Cast im Test. + +- [ ] **Step 2: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php` +Expected: FAIL, Klasse fehlt. + +- [ ] **Step 3: Implementieren** + +```php + value like lit/divi-node-get –, its nested + * group presets with names, the posts that use it (readable ones by title, the rest as a count) and the design_rev + * for a following lit/divi-presets-set. + */ +final class DiviPresetGetAbility implements Ability +{ + public function __construct(private DesignSystemWriter $writer, private GlobalUsages $usages) + { + } + + public function name(): string + { + return 'lit/divi-preset-get'; + } + + public function label(): string + { + return 'Divi-Preset lesen'; + } + + public function description(): string + { + return 'Liest ein Modul-Preset (module, z. B. "button") oder Gruppen-Preset (group, z. B. "font-body") mit ' + . 'seinen Einstellungen (set in Kurzform, attrs als Attributpfad → Wert), verschachtelten Gruppen-Presets, ' + . 'den Seiten, die es verwenden (usage), und der design_rev für lit/divi-presets-set. preset = Name oder ' + . 'ID aus lit/divi-design-system (direkt: lit-divi-design-system).'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" (Modul-Preset).'], + 'group' => ['type' => 'string', 'description' => 'Gruppe, z. B. "font-body" (Gruppen-Preset).'], + 'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'], + ], + 'required' => ['preset'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'kind' => ['type' => 'string'], + 'scope' => ['type' => 'string'], + 'default' => ['type' => 'boolean'], + 'module_name' => ['type' => 'string'], + 'group_id' => ['type' => ['string', 'null']], + 'set' => ['type' => 'object'], + 'attrs' => ['type' => 'object'], + 'group_presets' => ['type' => 'array', 'items' => ['type' => 'object']], + 'usage' => ['type' => 'object'], + 'design_rev' => ['type' => 'string'], + ], + 'required' => ['id', 'name', 'kind', 'scope', 'default', 'module_name', 'group_id', 'set', 'attrs', + 'group_presets', 'usage', 'design_rev'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Presets lesen erfordert das Recht, Beiträge zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + try { + $scope = PresetScope::fromInput($input, 'input'); + } catch (InvalidArgumentException $e) { + return Errors::invalidInput('Modul oder Gruppe fehlt bzw. ist ungültig.', ['module' => $e->getMessage()]); + } + + $book = PresetBook::fromDivi($this->writer->presets()); + $wanted = is_string($input['preset'] ?? null) ? $input['preset'] : ''; + try { + $item = $book->find($scope, $wanted); + } catch (InvalidArgumentException $e) { + return Errors::invalidInput('Der Preset-Name ist mehrdeutig.', ['preset' => $e->getMessage()]); + } + if ($item === null) { + return Errors::notFound( + sprintf('%s „%s“ gibt es nicht.', $scope->label(), $wanted), + 'Vorhandene Presets mit lit/divi-design-system (direkt: lit-divi-design-system) und module auflisten.' + ); + } + + $id = (string) $item['id']; + $isDefault = $book->defaultId($scope) === $id; + $attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : []; + $module = is_string($item['moduleName'] ?? null) ? $item['moduleName'] : $scope->name; + $usage = $this->usages->reportFor( + new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => $isDefault]]), + false + ); + + return [ + 'id' => $id, + 'name' => (string) ($item['name'] ?? $id), + 'kind' => $scope->kind, + 'scope' => $scope->name, + 'default' => $isDefault, + 'module_name' => $module, + 'group_id' => is_string($item['groupId'] ?? null) ? $item['groupId'] : null, + 'set' => JsonMap::of(ShortformMap::values(substr($module, (int) strpos($module, '/') + 1), $attrs)), + 'attrs' => JsonMap::of(AttrMap::flatten($attrs)), + 'group_presets' => $this->nested($book, $item), + 'usage' => $usage->listing(), + 'design_rev' => $book->rev(), + ]; + } + + /** + * @param array $item + * @return list}> + */ + private function nested(PresetBook $book, array $item): array + { + $rows = []; + foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $groupId => $nested) { + $groupName = is_array($nested) && is_string($nested['groupName'] ?? null) ? $nested['groupName'] : ''; + $presets = []; + foreach (is_array($nested) ? (array) ($nested['presetId'] ?? []) : [] as $presetId) { + $found = $book->items(new PresetScope(PresetScope::GROUP, $groupName))[(string) $presetId] ?? null; + $presets[] = ['id' => (string) $presetId, 'name' => (string) ($found['name'] ?? $presetId)]; + } + $rows[] = ['group_id' => (string) $groupId, 'group_name' => $groupName, 'presets' => $presets]; + } + + return $rows; + } +} +``` + +`ShortformMap::values()` erwartet den kurzen Modultyp (`button`), wie `Block::shortName()` ihn aus `divi/button` bildet; für einen Typ ohne Kurzform liefert es `[]` (`fields()` gibt dann `[]`). + +In `DiviIntegration`: +- Eigenschaft `private DesignSystemWriter $designWriter;`, im Konstruktor `$this->designWriter = new DesignSystemWriter();`. +- In `abilities()` am Ende `new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),` ergänzen; Import. + +In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` die Namensliste um `'lit/divi-preset-get',` ergänzen (hinter `'lit/divi-dynamic-sources',`). + +- [ ] **Step 4: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/DiviPresetGetAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +git commit -m "feat: lit/divi-preset-get – Preset mit Einstellungen und Fundstellen lesen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 11: `lit/divi-presets-set` – Presets anlegen und ändern + +**Files:** +- Create: `src/Integrations/Divi/Design/DesignRights.php` +- Create: `src/Integrations/Divi/Design/DiviPresetsSetAbility.php` +- Modify: `src/Integrations/Divi/Design/PresetDiff.php` (neue Methode `summary()`) +- Modify: `tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php` + +**Interfaces:** +- Consumes: `DesignChange::run()`, `DesignChange::inputProperties()`, `DesignChange::outputSchema()`, `DesignPlan`, `PresetArea`, `PresetBook`, `PresetScope`, `PresetDiff`, `PresetComposer`, `PresetUsageQuery`, `GlobalUsages::reportFor()`, `DiviPageLoader::load(mixed, bool)` (→ `DiviPage|WP_Error`, `$page->index->find(string): ?Block`). +- Produces: + - `final class DesignRights { public static function presets(DesignSystemWriter $writer): ?WP_Error }` – `edit_theme_options` + Visual Builder. + - `PresetDiff::summary(array $diff): string` – z. B. „Presets: 1 angelegt, 1 geändert, Standard 1× gesetzt.“ + - `final class DiviPresetsSetAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages, DiviPageLoader $loader, Closure $composer) }` – `$composer`: `Closure(): PresetComposer`. + +Eingabe (Spec §6.3): `items` (1–20) mit je `module`|`group`, `preset` (zu ändern: Name oder ID; fehlt → neu), `name` (neu), `rename`, `set`, `attrs`, `from_node` (`{post_id, id}`), `source_module`, `group_id`, `group_presets`, `make_default` (bool); dazu `confirm`, `design_rev`. + +- [ ] **Step 1: Failing tests für `PresetDiff::summary()` und `DesignRights` schreiben** + +In `PresetDiffTest` anhängen: + +```php + public function testSummaryCountsTheActions(): void + { + self::assertSame( + 'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.', + PresetDiff::summary([ + ['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'], + ['action' => 'default_set'], ['action' => 'default_cleared'], + ]) + ); + self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']])); + self::assertSame('Presets: keine Änderung.', PresetDiff::summary([])); + } +``` + +`tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`: + +```php +allows('canUseVisualBuilder')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::presets($writer)); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('Visual Builder', $refusal['message']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` +Expected: FAIL. + +- [ ] **Step 3: `summary()` und `DesignRights` implementieren** + +In `PresetDiff`: + +```php + /** One German sentence for the dry run and the change log (spec §4 step 6). */ + public static function summary(array $diff): string + { + $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); + $parts = array_filter([ + isset($count['added']) ? $count['added'] . ' angelegt' : null, + isset($count['changed']) ? $count['changed'] . ' geändert' : null, + isset($count['removed']) ? $count['removed'] . ' gelöscht' : null, + ]); + $defaults = array_filter([ + isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null, + isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null, + ]); + if ($defaults !== []) { + $parts[] = 'Standard ' . implode(', ', $defaults); + } + + return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; + } +``` + +(`@param list> $diff` im Docblock ergänzen.) + +`src/Integrations/Divi/Design/DesignRights.php`: + +```php +canUseVisualBuilder()) { + return Errors::forbidden( + 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.', + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } + + return null; + } +} +``` + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` +Expected: PASS. + +- [ ] **Step 4: Failing test für die Ability schreiben** + +```php + */ + private array $stored; + + private SnapshotStore $snapshots; + + protected function setUp(): void + { + parent::setUp(); + $this->stored = PresetBookTest::stored(); + $this->snapshots = Mockery::mock(SnapshotStore::class); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null): DiviPresetsSetAbility + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturnUsing(fn (): array => $this->stored); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array { + $data = ['module' => [], 'group' => []]; + foreach ($rest as $kind => $records) { + foreach ($records as $record) { + $first = $record['items'][0] ?? null; + $name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x'); + $data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')]; + } + } + + return $this->stored = $data; + }); + $usages ??= Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); + $composer = static fn (): PresetComposer => new PresetComposer( + ComposeFixture::composer($unfiltered), + new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(), + ComposeFixture::sanitizer($unfiltered)), + static fn (string $module, array $attrs): array => ['all' => array_map( + static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v, + $attrs + ), 'style' => [], 'render' => []], + static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'], + '5.15.1', + static fn (): int => 1 + ); + + return new DiviPresetsSetAbility( + $writer, + new DesignChange($this->snapshots, new FakeChangeLog(5)), + $usages, + $loader ?? Mockery::mock(DiviPageLoader::class), + $composer + ); + } + + private static function newButton(array $extra = []): array + { + return ['items' => [['module' => 'button', 'name' => 'CTA dunkel', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]] + $extra]]; + } + + public function testDryRunThenConfirmCreatesThePreset(): void + { + $ability = $this->ability(); + + $dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required'); + self::assertSame('added', $dry['diff'][0]['action']); + self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']); + + $this->snapshots->expects('save')->andReturn('presets-1'); + $this->snapshots->expects('attachLog'); + $result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertTrue($result['ok']); + self::assertSame('presets-1', $result['snapshot_id']); + $names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name'); + self::assertContains('CTA dunkel', $names); + } + + public function testExistingNameIsRefusedForANewPreset(): void + { + $result = self::assertToolError($this->ability()->execute(['items' => [ + ['module' => 'button', 'name' => 'CTA primär'], + ]]), 'validation_failed'); + + self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']); + } + + public function testChangingAndMakingDefault(): void + { + $dry = self::assertToolError($this->ability()->execute(['items' => [[ + 'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true, + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]], + ]]]), 'confirm_required'); + + self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action')); + } + + public function testUnknownPresetAndBadItemsAreCollected(): void + { + $result = self::assertToolError($this->ability()->execute(['items' => [ + ['module' => 'button', 'preset' => 'Fehlt'], + ['name' => 'ohne Modul'], + ]]), 'validation_failed'); + + self::assertArrayHasKey('items[0].preset', $result['fields']); + self::assertArrayHasKey('items[1]', $result['fields']); + } + + public function testEditorWithoutUnfilteredHtmlIsRefusedForRawCss(): void + { + $result = $this->ability(false)->execute(['items' => [[ + 'module' => 'button', 'name' => 'Mit CSS', + 'attrs' => ['css' => ['desktop' => ['mainElement' => 'color:red']]], + ]]]); + + self::assertNotTrue(is_array($result) && ($result['ok'] ?? false)); + self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']); + } + + public function testFromNodeReadsThePage(): void + { + $loader = Mockery::mock(DiviPageLoader::class); + $content = '' + . '' + . ''; + $document = (new Parser())->parse($content); + $page = new DiviPage(DiviFixtures::givenPost($content), $document, NodeIndex::build($document)); + $loader->expects('load')->with(12, false)->andReturn($page); + + $dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[ + 'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 12, 'id' => 's0.r0.c0.m0'], + ]]]), 'confirm_required'); + + self::assertSame('added', $dry['diff'][0]['action']); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden'); + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + + self::assertSame('lit/divi-presets-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + } +} +``` + +`DiviFixtures::givenPost(string $content, array $fields = []): WP_Post` baut den Beitrag ohne WordPress. + +Die Pfad-ID des Buttons im Testinhalt ist `s0.r0.c0.m0` (`NodeIndex::ID_PATTERN`: `s` Abschnitt, `r` Zeile, `c` Spalte, `m` Modul). + +- [ ] **Step 5: Test laufen lassen – er scheitert** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php` +Expected: FAIL, Klasse fehlt. + +- [ ] **Step 6: Ability implementieren** + +```php + 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'], + 'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'], + 'rename' => ['type' => 'string'], + 'set' => ['type' => 'object'], + 'attrs' => ['type' => 'object'], + 'from_node' => [ + 'type' => 'object', + 'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']], + 'required' => ['post_id', 'id'], + ], + 'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'], + 'group_id' => ['type' => 'string'], + 'group_presets' => ['type' => 'object'], + 'make_default' => ['type' => 'boolean'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + 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; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new PresetArea($this->writer), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(PresetBook $before, array $items): DesignPlan|WP_Error + { + $composer = ($this->composer)(); + $book = $before; + $errors = []; + $invalid = []; + $targets = []; + $warnings = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $prepared = $this->prepare($book, $item, $field, $errors); + if ($prepared === null) { + continue; + } + [$scope, $existing, $source] = $prepared; + $result = $composer->compose($book, $scope, $existing, $item, $field, $source); + $invalid = array_merge($invalid, $result['invalid_paths']); + if ($result['errors'] !== [] || $result['invalid_paths'] !== []) { + $errors += $result['errors']; + continue; + } + $id = (string) $result['item']['id']; + $book = $book->with($scope, $result['item']); + if (($item['make_default'] ?? null) === true) { + $book = $book->withDefault($scope, $id); + if ($scope->kind === PresetScope::GROUP) { + $warnings[] = self::GROUP_DEFAULT_WARNING; + } + } elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) { + $book = $book->withDefault($scope, ''); + } + if ($existing !== null || $book->defaultId($scope) === $id) { + $targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id + || $book->defaultId($scope) === $id]; + } + } + + if ($invalid !== []) { + return Errors::invalidAttrPath($errors); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = PresetDiff::between($before, $book); + + return new DesignPlan( + $book->data(), + $diff, + PresetDiff::summary($diff), + $targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)), + array_values(array_unique($warnings)) + ); + } + + /** + * Scope, preset to change and source node of one item; null after noting the problem in $errors. + * + * @param array $item + * @param array $errors + * @return array{PresetScope, array|null, Block|null}|null + */ + private function prepare(PresetBook $book, array $item, string $field, array &$errors): ?array + { + try { + $scope = PresetScope::fromInput($item, $field); + } catch (InvalidArgumentException $e) { + [$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => '']; + $errors[$path] = $problem; + + return null; + } + + $existing = null; + try { + if (is_string($item['preset'] ?? null)) { + $existing = $book->find($scope, $item['preset']); + if ($existing === null) { + $errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']); + + return null; + } + } + } catch (InvalidArgumentException $e) { + $errors[$field . '.preset'] = $e->getMessage(); + + return null; + } + + foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) { + if (! $applies || ! is_string($item[$key] ?? null)) { + continue; + } + $taken = array_diff($book->idsNamed($scope, $item[$key]), [(string) ($existing['id'] ?? '')]); + if ($taken !== []) { + $errors[$field . '.' . $key] = sprintf( + 'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen', + implode(', ', $taken) + ); + + return null; + } + } + + $source = $this->source($scope, $item, $field, $errors); + if ($source === false) { + return null; + } + + return [$scope, $existing, $source]; + } + + /** + * @param array $item + * @param array $errors + */ + private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null + { + $from = $item['from_node'] ?? null; + if ($from === null) { + return null; + } + if (! is_array($from) || ! is_string($from['id'] ?? null)) { + $errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet'; + + return false; + } + + $page = $this->loader->load($from['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + $errors[$field . '.from_node.post_id'] = Errors::clientMessage($page); + + return false; + } + $block = $page->index->find($from['id']); + if ($block === null) { + $errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']); + + return false; + } + if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) { + $errors[$field . '.from_node.id'] = sprintf('%s ist %s, nicht %s', $from['id'], $block->name(), $scope->name); + + return false; + } + + return $block; + } +} +``` + +`false` steht für „Fehler schon in `$errors` notiert“, `null` für „kein `from_node`“ (`Block|false|null` ist ab PHP 8.0 gültig). + +In `DiviIntegration::abilities()` ergänzen: + +```php + new DiviPresetsSetAbility( + $this->designWriter, + $this->designChange(), + $this->globalUsages(), + $loader, + $this->presetComposer(...) + ), +``` + +und die Helfer: + +```php + private function designChange(): DesignChange + { + return new DesignChange(new SnapshotStore(), $this->log); + } + + private function presetComposer(): PresetComposer + { + $composition = new DiviComposition($this->modules, $this->design); + + return new PresetComposer( + $composition->composer(), + $composition->storedGuard(), + $this->designWriter->presetAttrs(...), + $this->designWriter->groupsOf(...), + $this->modules->builderVersion(), + static fn (): int => (int) floor(microtime(true) * 1000) + ); + } +``` + +Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-set',` ergänzen. + +- [ ] **Step 7: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 8: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/DesignRights.php src/Integrations/Divi/Design/DiviPresetsSetAbility.php src/Integrations/Divi/Design/PresetDiff.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +git commit -m "feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: `lit/divi-presets-delete` – unbenutzte Presets löschen + +**Files:** +- Create: `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php` +- Modify: `src/Abilities/Errors.php` (`inUse()` mit eigenem Hinweis) +- Modify: `tests/Unit/Abilities/ErrorsTest.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php` + +**Interfaces:** +- Consumes: wie Task 11, dazu `PresetBook::without()`, `PresetBook::nestedUsers()`, `UsageReport::exists()/listing()`. +- Produces: + - `Errors::inUse(string $message, array $usages, ?string $hint = null): WP_Error` – ohne `$hint` unverändert („… nur mit force: true …“, für `lit/media-delete`). + - `final class DiviPresetsDeleteAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages) }`. + +- [ ] **Step 1: Failing tests schreiben** + +In `tests/Unit/Abilities/ErrorsTest.php` anhängen: + +```php + public function testInUseTakesItsOwnHint(): void + { + $error = Errors::inUse('Wird verwendet.', [['post_id' => 3]], 'Erst die Verweise entfernen.'); + + $payload = self::assertToolError($error, 'in_use'); + self::assertSame('Erst die Verweise entfernen.', $payload['hint']); + self::assertStringContainsString('force: true', Errors::inUse('x', [])->get_error_data()['hint']); + } +``` + +(`ErrorsTest` erbt von `LIT\MCP\Tests\Unit\TestCase` und hat damit `assertToolError()`.) + +`tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`: + +```php +justReturn(true); + if ($writer === null) { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + } + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return new DiviPresetsDeleteAbility( + $writer, + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages + ); + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + public function testUnusedPresetIsDeletedAfterConfirm(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets') + ->with(Mockery::on(static function (array $rest): bool { + $ids = array_column($rest['module'][0]['items'], 'id'); + + return $ids === ['aaaaaaaaaa', 'cccccccccc']; + }), true) + ->andReturnUsing(static function (): array { + $stored = PresetBookTest::stored(); + unset($stored['module']['divi/button']['items']['bbbbbbbbbb']); + + return $stored; + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-9'); + $snapshots->expects('attachLog'); + $ability = $this->ability(self::unused(), $writer, $snapshots); + $input = ['items' => [['module' => 'button', 'preset' => 'bbbbbbbbbb']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertSame('removed', $dry['diff'][0]['action']); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('presets-9', $result['snapshot_id']); + } + + public function testPresetInUseIsRefused(): void + { + $used = new UsageReport(2, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 1, 1, [], 0, false); + + $error = self::assertToolError( + $this->ability($used)->execute(['items' => [['module' => 'button', 'preset' => 'CTA primär']]]), + 'in_use' + ); + + self::assertSame(3, $error['usages'][0]['post_id']); + self::assertStringContainsString('lit/divi-design-detach', $error['hint']); + } + + public function testNestedUseInAnotherPresetCounts(): void + { + $error = self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['group' => 'font-body', 'preset' => 'Lesetext']]]), + 'in_use' + ); + + self::assertSame('preset', $error['usages'][0]['type']); + self::assertSame('dddddddddd', $error['usages'][0]['preset_id']); + } + + public function testDeletingTheDefaultClearsIt(): void + { + $dry = self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['module' => 'text', 'preset' => 'Fließtext']]]), + 'confirm_required' + ); + + self::assertSame(['removed', 'default_cleared'], array_column($dry['diff'], 'action')); + } + + public function testUnknownPreset(): void + { + self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['module' => 'button', 'preset' => 'Fehlt']]]), + 'validation_failed' + ); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php` +Expected: FAIL. + +- [ ] **Step 3: `Errors::inUse()` erweitern** + +```php + /** + * @param list> $usages + * @param string|null $hint Replaces the default hint (force: true, for lit/media-delete). + */ + public static function inUse(string $message, array $usages, ?string $hint = null): WP_Error + { + return self::make( + 'in_use', + $message, + $hint ?? 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.', + 409, + ['usages' => $usages] + ); + } +``` + +- [ ] **Step 4: Ability implementieren** + +```php + 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'preset' => ['type' => 'string'], + ], + 'required' => ['preset'], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(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; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gelöscht.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new PresetArea($this->writer), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(PresetBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + $targets = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + try { + $scope = PresetScope::fromInput(is_array($item) ? $item : [], $field); + $found = $before->find($scope, is_string($item['preset'] ?? null) ? $item['preset'] : ''); + } catch (InvalidArgumentException $e) { + $errors[$field] = $e->getMessage(); + continue; + } + if ($found === null) { + $errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), (string) $item['preset']); + continue; + } + $id = (string) $found['id']; + $targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id]; + $book = $book->without($scope, $id); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Angaben; nichts wurde gelöscht.', $errors); + } + + $deleted = array_column($targets, 'id'); + $nested = []; + foreach ($targets as $target) { + foreach ($before->nestedUsers($target['id']) as $user) { + if (! in_array($user['id'], $deleted, true)) { + $nested[] = ['type' => 'preset', 'preset_id' => $user['id'], 'name' => $user['name'], + 'scope' => $user['scope']->name]; + } + } + } + $report = $this->usages->reportFor(new PresetUsageQuery($targets), false); + if ($nested !== [] || $report->exists()) { + return Errors::inUse( + 'Die Presets werden noch verwendet; nichts wurde gelöscht.', + array_merge($nested, $report->listing()['affected']), + self::DETACH_HINT + ); + } + + $diff = PresetDiff::between($before, $book); + + return new DesignPlan($book->data(), $diff, PresetDiff::summary($diff), null, [], true); + } +} +``` + +Bei `PresetScope::fromInput()`-Fehlern steht der Pfad schon in der Meldung (`items[0]: …`); hier wird die ganze Meldung unter `items[i]` abgelegt – im Test reicht der Code `validation_failed`. + +In `DiviIntegration::abilities()` ergänzen: `new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),`; Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-delete',`. + +Der Hinweis nennt `lit/divi-design-detach`, das erst mit 4b registriert wird; `ToolNamingTest` erlaubt das (er prüft nur die Schreibweise direkter und indirekter Tools). + +- [ ] **Step 5: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 6: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Abilities/Errors.php src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +git commit -m "feat: lit/divi-presets-delete – unbenutzte Presets löschen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 13: `lit/divi-design-snapshots` und `lit/divi-design-restore` + +**Files:** +- Create: `src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php` +- Create: `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php` +- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php` + +**Interfaces:** +- Consumes: `SnapshotStore::list()/get()`, `Cursor::inputProperties()/decode()/limit()/page()`, `DesignChange::run()`, `PresetArea`, `PresetBook`, `PresetDiff`, `DesignRights::presets()`. +- Produces: + - `final class DiviDesignSnapshotsAbility implements Ability { public function __construct(SnapshotStore $snapshots) }` – Name `lit/divi-design-snapshots`, Eingabe `area` (Standard `presets`), `cursor`, `limit`; Ausgabe `{area, snapshots, next_cursor}`; Recht `edit_theme_options`. + - `final class DiviDesignRestoreAbility implements Ability { public function __construct(DesignSystemWriter $writer, DesignChange $change, SnapshotStore $snapshots) }` – Name `lit/divi-design-restore`, Eingabe `snapshot_id`, `confirm`, `design_rev`. In 4a nur Bereich `presets`; andere Bereiche → `unsupported` („ab Etappe 4c/4d“). + +`Cursor` (vorhanden): `decode(?string): ?int` (0 ohne Cursor, `null` = ungültig), `limit(mixed): int` (Standard 20, höchstens 100), `page(array $rows, int $limit, int $offset): array{items, next_cursor}` – `$rows` beginnen beim Offset (der Aufrufer schneidet ab), `next_cursor` entsteht, wenn mehr als `$limit` Zeilen übergeben werden. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php`: + +```php +alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + $store = Mockery::mock(SnapshotStore::class); + $store->expects('list')->with('presets')->andReturn([ + ['id' => 'presets-2', 'created' => '2026-10-16T10:00:00+00:00', 'user_id' => 1, + 'ability' => 'lit/divi-presets-set', 'log_id' => 4, 'size' => 120], + ]); + $ability = new DiviDesignSnapshotsAbility($store); + + self::assertTrue($ability->permission([])); + $result = $ability->execute([]); + + self::assertSame('presets', $result['area']); + self::assertSame('presets-2', $result['snapshots'][0]['id']); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testUnknownAreaAndRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new DiviDesignSnapshotsAbility(Mockery::mock(SnapshotStore::class)); + + self::assertToolError($ability->permission([]), 'forbidden'); + Functions\when('current_user_can')->justReturn(true); + self::assertToolError($ability->execute(['area' => 'seiten']), 'validation_failed'); + } +} +``` + +`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`: + +```php +justReturn(true); + $older = PresetBookTest::stored(); + unset($older['module']['divi/button']['items']['bbbbbbbbbb']); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets')->with(Mockery::type('array'), true)->andReturn($older); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('presets', 'presets-1')->andReturn(['id' => 'presets-1', 'data' => $older, + 'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-presets-set', 'log_id' => 2, 'size' => 1]); + $snapshots->expects('save')->with('presets', Mockery::type('array'), 'lit/divi-design-restore')->andReturn('presets-3'); + $snapshots->expects('attachLog'); + $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-1']), 'confirm_required'); + self::assertSame('removed', $dry['diff'][0]['action']); + + $result = $ability->execute(['snapshot_id' => 'presets-1', 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('presets-3', $result['snapshot_id']); + } + + public function testUnknownSnapshotAndOtherAreas(): void + { + Functions\when('current_user_can')->justReturn(true); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->andReturn(null); + $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots); + + self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported'); + self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie scheitern** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php` +Expected: FAIL, Klassen fehlen. + +- [ ] **Step 3: `DiviDesignSnapshotsAbility` implementieren** + +```php + 'object', + 'properties' => [ + 'area' => ['type' => 'string', 'enum' => SnapshotStore::AREAS], + ] + Cursor::inputProperties(), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'area' => ['type' => 'string'], + 'snapshots' => ['type' => 'array', 'items' => ['type' => 'object']], + 'next_cursor' => ['type' => ['string', 'null']], + ], + 'required' => ['area', 'snapshots', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Snapshots ansehen erfordert das Recht, das Design anzupassen (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $area = $input['area'] ?? 'presets'; + if (! in_array($area, SnapshotStore::AREAS, true)) { + return Errors::invalidInput('Unbekannter Bereich.', ['area' => implode(', ', SnapshotStore::AREAS)]); + } + $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + $limit = Cursor::limit($input['limit'] ?? null); + $page = Cursor::page(array_slice($this->snapshots->list($area), $offset, $limit + 1), $limit, $offset); + + return ['area' => $area, 'snapshots' => $page['items'], 'next_cursor' => $page['next_cursor']]; + } +} +``` + +- [ ] **Step 4: `DiviDesignRestoreAbility` implementieren** + +```php + 'object', + 'properties' => ['snapshot_id' => ['type' => 'string']] + DesignChange::inputProperties(), + 'required' => ['snapshot_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + 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; + } + $id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : ''; + if (preg_match(self::ID, $id, $match) !== 1) { + return Errors::invalidInput('Ungültige Snapshot-ID.', ['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3']); + } + if ($match[1] !== 'presets') { + return Errors::unsupported( + 'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.', + 'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.' + ); + } + $snapshot = $this->snapshots->get('presets', $id); + if ($snapshot === null) { + return Errors::notFound( + sprintf('Snapshot %s gibt es nicht (mehr).', $id), + 'Vorhandene Snapshots mit lit/divi-design-snapshots auflisten; je Bereich bleiben die letzten 20.' + ); + } + + return $this->change->run( + $this->name(), + new PresetArea($this->writer), + $input, + static function (array $raw) use ($snapshot, $id): DesignPlan { + $before = PresetBook::fromDivi($raw); + $after = PresetBook::fromDivi($snapshot['data']); + $diff = PresetDiff::between($before, $after); + + return new DesignPlan( + $after->data(), + $diff, + sprintf('Snapshot %s wiederherstellen – %s', $id, PresetDiff::summary($diff)), + null, + ['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul ' + . 'fällt dann auf das Standard-Preset zurück.'], + true + ); + } + ); + } +} +``` + +In `DiviIntegration::abilities()` ergänzen: + +```php + new DiviDesignSnapshotsAbility(new SnapshotStore()), + new DiviDesignRestoreAbility($this->designWriter, $this->designChange(), new SnapshotStore()), +``` + +Namensliste in `DiviIntegrationTest` um `'lit/divi-design-snapshots', 'lit/divi-design-restore',` ergänzen. + +- [ ] **Step 5: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` +Expected: PASS. + +- [ ] **Step 6: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php src/Integrations/Divi/Design/DiviDesignRestoreAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +git commit -m "feat: lit/divi-design-snapshots und lit/divi-design-restore (Bereich presets)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 14: Guide, Ende-zu-Ende-Test gegen echtes Divi, Entscheidungsnotiz + +**Files:** +- Modify: `docs/guides/divi-design-system.md` +- Create: `tests/Integration/divi-presets-smoke.sh` +- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` + +**Interfaces:** +- Consumes: alle Tools aus Tasks 10–13; `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`). +- Produces: Smoke `divi-presets-smoke.sh`, der grün endet mit `ALL CHECKS PASSED`. + +- [ ] **Step 1: Guide ergänzen** + +In `docs/guides/divi-design-system.md` nach den Regeln anhängen: + +```markdown +## Presets anlegen, ändern, löschen + +Über `mcp-adapter-execute-ability`: + +- `lit/divi-preset-get` – ein Preset mit Einstellungen (`attrs` als Pfad → Wert wie bei `lit-divi-node-get`), + verschachtelten Gruppen-Presets und den Seiten, die es verwenden (`usage`). +- `lit/divi-presets-set` – `items` mit `module` (z. B. `"button"`) oder `group` (z. B. `"font-body"`): + - neu: `name` + Einstellungen; ändern: `preset` (Name oder ID), optional `rename`; + - Einstellungen in `attrs` (Vollform, Platzhalter `{{color:…}}`, `{{var:…}}`, `{{font:…}}`) oder aus einem + gestalteten Modul: `from_node` = `{"post_id": 12, "id": "s0.r0.c1.m2"}` (Pfad-ID aus `lit-divi-outline`); + - Presets tragen nur Gestaltung – Texte, Links und Bilder gehören ins Modul (sonst `validation_failed`); + - `group_presets` (nur Modul-Presets), z. B. `{"font": "Button-Schrift"}`; `make_default: true` macht das + Preset zum Standard seines Moduls bzw. seiner Gruppe; + - neue Gruppen-Presets brauchen das Modul, aus dem die Gruppe stammt: `source_module` oder `from_node`. +- `lit/divi-presets-delete` – nur Presets, die nirgends verwendet werden; sonst `in_use` mit den Fundstellen. +- `lit/divi-design-snapshots` / `lit/divi-design-restore` – vor jeder Änderung legt LIT MCP einen Snapshot an + (je Bereich die letzten 20); wiederherstellen mit `snapshot_id`. + +Ablauf jeder Änderung: Aufruf ohne `confirm` = Probelauf (`confirm_required` mit `diff`, betroffenen Seiten und +`design_rev`), dem Nutzer die Änderung zeigen, dann derselbe Aufruf mit `confirm: true` und `design_rev`. Hat sich +der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen. + +Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder. +``` + +- [ ] **Step 2: Smoke-Skript schreiben** + +`tests/Integration/divi-presets-smoke.sh`: + +```bash +#!/usr/bin/env bash +# End-to-end checks of Etappe 4a (presets: read, create, change, default, delete, snapshots, restore) 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-smoke.sh +# Creates presets named "LIT Smoke …" and one draft page; the EXIT trap restores the preset inventory from the +# snapshot taken before the first change and deletes the page (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());') +PAGE_ID="" + +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" + if [ -n "$PAGE_ID" ]; then wp post delete "$PAGE_ID" --force >/dev/null 2>&1 || true; fi + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +export LIT_PRESETS="$ORIGINAL_PRESETS" +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +connect "$ADMIN_LOGIN" + +# 1. Create a button preset: dry run, then confirm. +NEW=$(jq -cn --arg n "$PREFIX CTA" '{items: [{module: "button", name: $n, + attrs: {"button.decoration.background": {desktop: {color: "#123456"}}}}]}') +DRY=$(ability lit/divi-presets-set "$NEW") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "create without confirm: $DRY" +REV=$(echo "$DRY" | jq -r '.__error | fromjson | .design_rev') +DONE=$(ability lit/divi-presets-set "$(echo "$NEW" | jq -c --arg r "$REV" '. + {confirm: true, design_rev: $r}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "create with confirm: $DONE" +SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id') +pass "preset created (snapshot $SNAPSHOT)" + +# 2. Read it back through Divi and the tool. +GET=$(ability lit/divi-preset-get "$(jq -cn --arg p "$PREFIX CTA" '{module: "button", preset: $p}')") +ID=$(echo "$GET" | jq -r '.id') +[ "$(echo "$GET" | jq -r '.attrs["button.decoration.background.desktop.value.color"]')" = "#123456" ] \ + || fail "preset-get attrs: $GET" +wp eval "\$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); exit(isset(\$d['module']['divi/button']['items']['$ID']['styleAttrs']) ? 0 : 1);" \ + || fail "Divi does not store styleAttrs for $ID" +pass "preset readable, styleAttrs stored" + +# 3. A page with the preset renders its class. +PAGE_ID=$(wp post create --post_type=page --post_status=draft --post_title="$PREFIX Seite" \ + --post_content="" \ + --porcelain) +HTML=$(wp eval "echo apply_filters('the_content', get_post($PAGE_ID)->post_content);") +echo "$HTML" | grep -q "preset--module--divi-button--$ID" || fail "rendered page lacks the preset class" +pass "page renders preset class" + +# 4. Deleting a used preset is refused. +DEL=$(jq -cn --arg p "$ID" '{items: [{module: "button", preset: $p}]}') +[ "$(error_code "$(ability lit/divi-presets-delete "$DEL")")" = "in_use" ] || fail "delete of a used preset" +pass "used preset not deletable" + +# 5. Stale design_rev. +STALE=$(ability lit/divi-presets-set "$(echo "$NEW" | jq -c '.items[0].name += " 2" | . + {confirm: true, design_rev: "veraltet"}')") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" +pass "stale design_rev refused" + +# 6. Restore the snapshot taken before step 1: the preset is gone again. +DRY=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" '{snapshot_id: $s}')") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "restore dry run: $DRY" +REV=$(echo "$DRY" | jq -r '.__error | fromjson | .design_rev') +RESTORED=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" --arg r "$REV" '{snapshot_id: $s, confirm: true, design_rev: $r}')") +[ "$(echo "$RESTORED" | jq -r '.ok')" = "true" ] || fail "restore: $RESTORED" +wp eval "\$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); exit(isset(\$d['module']['divi/button']['items']['$ID']) ? 1 : 0);" \ + || fail "restored inventory still has $ID" +pass "snapshot restored" + +finish +``` + +Die Helfer `cleanup_app_passwords_for`, `guard_exit`, `pass`, `fail`, `finish` stammen aus `lib.sh`; der Trap folgt dem Muster aus `divi-library-smoke.sh` (`guard_exit` macht jedes Ende ohne `finish` zum Fehler). Der Preset-Bestand wird direkt in Divis Option zurückgeschrieben – unabhängig davon, ob das Plugin noch funktioniert. + +- [ ] **Step 3: Smoke laufen lassen** + +Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh` +Expected: sechs `ok - …`-Zeilen und `ALL CHECKS PASSED`. Danach prüfen, dass der Preset-Bestand wieder dem Stand vor dem Lauf entspricht: +`ddev wp eval 'echo count(ET\Builder\Packages\GlobalData\GlobalPreset::get_data()["module"]["divi/button"]["items"] ?? []);'` (gleiche Zahl wie vor dem Lauf). + +Scheitert Schritt 3 (Preset-Klasse fehlt), mit `ddev wp eval` die gerenderte Ausgabe ansehen und mit einer im Builder angelegten Fixture-Seite vergleichen (`[Fixture] Designsystem`, Seite 3182) – nicht den Test lockern. + +- [ ] **Step 4: Entscheidungsnotiz nachführen** + +In `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` am Ende ergänzen: + +```markdown +## Umsetzung 4a (Plan 2026-10-16-etappe-4a-presets.md) +- Pipeline `DesignChange` (alle Bereiche), Snapshots `SnapshotStore`, Bereich `presets`. +- Tools: lit/divi-preset-get, lit/divi-presets-set, lit/divi-presets-delete, lit/divi-design-snapshots, lit/divi-design-restore (alle indirekt). +- Schreiben über die Schrittfolge von `GlobalPresetController::sync` (Mirror `preset-sync`). +- Offen für 4b: Fundstellen von Standard-Gruppen-Presets (Gruppenliste je Modul), lit/divi-design-detach. +``` + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` + +```bash +git add docs/guides/divi-design-system.md tests/Integration/divi-presets-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md +git commit -m "test: Ende-zu-Ende-Test der Presets gegen Divi; Guide und Entscheidungsnotiz zu 4a" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Abschluss von 4a + +- [ ] `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`) grün. +- [ ] 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/`. +- [ ] Danach Plan 4b schreiben (Zuweisen an Seiten, Massenzuweisung, detach). diff --git a/docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md b/docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md index edf342c..3c68db9 100644 --- a/docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md +++ b/docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md @@ -99,7 +99,7 @@ Jedes Tool nimmt **mehrere Einträge** eines Bereichs je Aufruf (ein Snapshot, e ### 6.2 Lesen: `lit/divi-preset-get` - Eingabe: `module` oder `group`, `id` oder `name`. -- Antwort: `id`, `name`, `kind`, `module`/`group`, `default` (Standard-Preset?), `set` (Einstellungen in Kurzform, soweit abbildbar), `attrs` (Vollform, nur was nicht in `set` steht), verschachtelte `group_presets` mit Namen, Platzhalter statt Roh-Referenzen (`{{color:…}}`, `{{var:…}}`), `usage` (Fundstellen wie §4 Schritt 4, gekappt), `design_rev`. +- Antwort: `id`, `name`, `kind`, `module`/`group`, `default` (Standard-Preset?), `set` (Einstellungen in Kurzform, soweit abbildbar), `attrs` (Vollform, nur was nicht in `set` steht), verschachtelte `group_presets` mit Namen, Werte im selben Format wie `lit-divi-node-get` (Roh-Verweise `$variable(…)$`; Namen und Platzhalter liefert `lit-divi-design-system`), `usage` (Fundstellen wie §4 Schritt 4, gekappt), `design_rev`. - `lit-divi-design-system` bleibt die Übersicht (Name, ID, Standard). ### 6.3 Anlegen und ändern: `lit/divi-presets-set` @@ -215,8 +215,8 @@ Alle Schreib-Tools sind Divi-Schreib-Tools im Sinne der Divi-Absicherung (gesper ## 11. Fehler und Antworten -- Neue Codes: `conflict` (design_rev), `in_use` (mit Fundstellen), `ambiguous_name` (mit IDs), `snapshot_too_large`, `snapshot_not_found`, `font_fetch_failed`, `font_invalid`. -- Vorhandene: `validation_failed` (`fields`), `forbidden`, `confirm_required` (Probelauf-Antwort mit `ok: true, dry_run: true`), `divi_untested`/`divi_incompatible`, `write_failed` (`rolled_back`/`partial`). +- Neue Codes: `conflict` (design_rev; alternativ `rev_mismatch` mit eigener Meldung, Plan entscheidet nach Bestand), `in_use` (vorhandener Code, mit Fundstellen), `ambiguous_name` (mit IDs), `snapshot_too_large`, `snapshot_not_found`, `font_fetch_failed`, `font_invalid`. +- Vorhandene: `validation_failed` (`fields`), `forbidden`, `confirm_required` (Probelauf-Antwort wie bei globalen Modulen und `lit/tb-activate`: Fehlerobjekt mit `diff`, `affected`, `design_rev` in den Details), `divi_untested`/`divi_incompatible`, `write_failed` (`rolled_back`/`partial`). - Jede Antwort mit `hint` bei Fehlern; Probeläufe nennen den nächsten Schritt (`confirm: true`, `remaining`). ## 12. Daten, Deinstallation, Sicherheit