diff --git a/composer.json b/composer.json index e7c6ee8..875196c 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "psr-4": { "LIT\\MCP\\Tests\\": "tests/" } }, "scripts": { - "test": "@php vendor/bin/phpunit", + "test": "@php -d memory_limit=512M vendor/bin/phpunit", "lint": "@php vendor/bin/phpcs" }, "config": { diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index f07f680..0fb25b6 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -38,9 +38,12 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt (HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst, -geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute -(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im -Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten +geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute, +Interaktionen und die Lottie-Quelle (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`, +`lottie.innerContent`) nur mit dem Recht `unfiltered_html`, sonst „nur im +Divi-Builder“ – Interaktionen können im Browser beliebige HTML-Attribute und Cookies setzen, die +Lottie-Quelle lädt eine Animation von einer fremden Adresse, die Code ausführen kann. Dieselbe Grenze gilt +beim Kopieren und Einfügen gespeicherter Knoten (siehe `divi-library`). Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). ## Pfade finden diff --git a/docs/guides/divi-design-system.md b/docs/guides/divi-design-system.md index ab092c9..6bcf720 100644 --- a/docs/guides/divi-design-system.md +++ b/docs/guides/divi-design-system.md @@ -11,6 +11,10 @@ - **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls. +Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile +(Schriften und Größen sind immer dabei), `module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens +200 Presets je Antwort (`notes` sagt, wenn es mehr gibt). + Regeln: 1. Vor dem Gestalten einer Seite das Designsystem lesen. diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md new file mode 100644 index 0000000..6fb9c54 --- /dev/null +++ b/docs/guides/divi-library.md @@ -0,0 +1,121 @@ +# Divi-Bibliothek und globale Module + +Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung. + +- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig. +- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des + Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf + veröffentlichten. + +## Werkzeuge + +- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` + (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` + (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: + ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen + (`affected`, `affected_count`, `hidden_count`). Erkannt werden Divi-5-Instanzen (auch mit maskierten Zeichen) + und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite + mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist + (Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen + können trotzdem unerkannt bleiben. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint + dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst. +- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen + Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. + Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` + der Zielseite ist Pflicht. Die Kopie eines globalen Eintrags ist vollständig gelöst (auch darin enthaltene + globale Module); in der Kopie eines normalen Eintrags bleiben globale Module verknüpft. `mode: "global"` + geht nur bei globalen Einträgen mit genau einem Abschnitt, einer Zeile oder einem Modul ohne globale Module + darin – sonst `mode: "copy"` verwenden. Nur aktive Einträge (nicht im Papierkorb) lassen sich einfügen. +- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`post_id`, + `tree_rev`, Pfad-ID `id`, `title`, `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der + Knoten auf der Seite bleibt eine eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz + ersetzen (siehe Ablauf unten). Ein Knoten, der selbst ein globales Modul ist oder eines enthält, lässt sich + nicht als globaler Eintrag speichern. +- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren + (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle – Pflicht, weil + Pfad-IDs nur für diesen Stand gelten; Ziel mit `post_id`, `tree_rev`, Position). Quelle und Ziel dürfen + Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Aus + einem Bibliothekseintrag zu kopieren ist Einfügen aus der Bibliothek: es braucht dieselben Rechte und + Freigaben wie `lit-library-insert`. +- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, + `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit/divi-duplicate-node` und + `lit/divi-replace-text` mit der ID des Eintrags als `post_id`. + +## Globale Module erkennen und ändern + +1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058] + (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite + lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. +2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. +3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf – ebenso bei einem normalen Eintrag, + auf den Seiten verweisen (Divi zeigt dort dessen aktuellen Inhalt): Antwort `confirm_required` mit + `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das + Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts. +4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit + `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). +5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine + Sicherungskopie im Papierkorb (`backup_id`). Schlägt das Speichern danach fehl, nennt der Fehler die + `backup_id`. Nach dem Speichern wird Divis CSS-Cache für alle Seiten geleert. + +Die Bestätigung wird abgelehnt (`forbidden`), wenn das Konto einen veröffentlichten, privaten oder geplanten +Eintrag, der das Modul zeigt, nicht bearbeiten darf – schon der Probelauf warnt davor und nennt diese IDs in +`blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht). +Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn die Divi-Rolle des Kontos +„Theme Builder“ nicht erlaubt. Zeigen +mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab +(`unsupported`); solche Einträge im Divi-Builder bearbeiten. Sind Revisionen für die Bibliothek abgeschaltet, lehnt das +Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein +`unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei). + +`lit/divi-replace-text` nimmt Bibliothekseinträge als `targets` (ID und `tree_rev` aus `lit-divi-outline`; +der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale (und normale, +auf die Seiten verweisen) nie – ihr +Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update` +und `confirm: true` vornehmen. + +Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses +Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; kein Eintrag +wird leer; ein globaler Eintrag enthält keine globalen Module. Nur aktive Einträge lassen sich ändern. + +`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt +sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen. + +## Rechte + +- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen + Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge + sind immer aktiv). +- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“ (anlegen und ändern), „Aus der + Bibliothek hinzufügen“ (einfügen), „Globale Bibliothekselemente bearbeiten“ (globale Einträge anlegen und + ändern). Fehlt „Divi-Bibliothek“, gilt jeder Eintrag als nicht vorhanden (`not_found`) und die Liste + antwortet mit `forbidden`; fehlt eines der anderen, antwortet das Werkzeug mit `forbidden` und diesem + Hinweis. +- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf + Bibliothekseinträge, auch über die Divi-Werkzeuge; deaktiviert gilt jeder Eintrag als nicht vorhanden. + Grenze: Die Einträge selbst sind dann nicht lesbar und nicht änderbar, aber `lit-divi-outline`, + `lit-divi-node-get` und `lit-page-render-summary` einer Seite nennen die ID eines dort verwendeten globalen + Moduls weiterhin (auch ohne das Recht „Divi-Bibliothek“). Diese ID mit den Divi-Werkzeugen zu öffnen, + antwortet `not_found`; dann nicht weiter versuchen. + `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist; + `lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle auch, wenn `lit-library-insert` gesperrt ist. +- Ohne das Recht `unfiltered_html` überträgt LIT MCP beim Kopieren, Einfügen und Speichern (`lit/divi-node-copy`, + `lit-library-insert`, `lit/library-save`) nur Knoten, die das Konto auch selbst schreiben dürfte. Abgelehnt + werden – „nur im Divi-Builder“ – Knoten mit eigenem HTML, CSS oder HTML-Attributen, Interaktionen, einer + Lottie-Quelle, dem Code-Modul, fremden Blöcken oder Freitext zwischen den Blöcken, Text mit HTML, das der + HTML-Filter von WordPress verändern würde, und `$variable`-Verweisen, die die normale Prüfung nicht + bestehen (z. B. HTML davor/danach). Das ist strenger als Divi selbst; die Antwort nennt jede Fundstelle + (`validation_failed`), gespeichert wird nichts. Relative Links (`kontakt/`, `/pfad`, `#anker`) bleiben + kopierbar. + +## Ablauf: globale Module vorschlagen + +1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder + Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action). +2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt + danach auf allen Seiten. +3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern. +4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt + einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen. +5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen. + +Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt. diff --git a/docs/guides/divi-placeholders.md b/docs/guides/divi-placeholders.md index 571e0e9..8145596 100644 --- a/docs/guides/divi-placeholders.md +++ b/docs/guides/divi-placeholders.md @@ -12,6 +12,7 @@ sich auch der Button. | `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext | | `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags | | `{{field:}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` | +| `{{link:}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden | | `"preset": ""` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | Beispiele: @@ -30,9 +31,19 @@ Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen; ist ein Name d Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_` beginnt (geschützte Meta-Daten), sind nicht erlaubt. -In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`; -sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine -Link-Variable). +In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:}}` verwenden statt fester +Adressen wie `/?page_id=…`. Der Eintrag muss existieren und für das Konto lesbar sein; Einträge im Papierkorb, +automatische Entwürfe und Medien (Anhänge – dafür die Adresse aus der Mediathek) lehnt das Werkzeug ab, +ebenso `{{link:…}}` außerhalb von Link-Feldern. Solange das Ziel ein Entwurf oder privat ist, zeigt Divi +Besuchern nur die Adresse `?page_id=` (kein Titel); nach dem Veröffentlichen die normale Adresse. Sonst +nimmt ein Link-Feld jeden dynamischen Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben +(`{{post:url}}`, ein Feld mit einer URL, eine Link-Variable). + +Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources` +(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als +`{{field:…}}` eignen. Gelistet werden nur textartige Felder; Beziehungs- und Dateifelder (Divi zeigt sie nur +als IDs), Tabellen-Felder und Felder unbekannten Typs stehen mit Grund unter `omitted`. Bei Auswahlfeldern +zeigt Divi den gespeicherten Wert, nicht die Beschriftung. ## `$variable`-Werte diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 1c9fb1b..f933779 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -106,8 +106,10 @@ Bedarf die Vorlage im Builder neu wählen). Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. -`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`, -`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der +`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute, Interaktionen und die Lottie-Quelle eines Moduls (`…decoration.attributes`, +`…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“ (Interaktionen können im +Browser beliebige HTML-Attribute und Cookies setzen, die Lottie-Quelle lädt Animationen von fremden Adressen, +die Code ausführen können). Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). diff --git a/docs/guides/divi-structure.md b/docs/guides/divi-structure.md index 53e42d8..1a70324 100644 --- a/docs/guides/divi-structure.md +++ b/docs/guides/divi-structure.md @@ -38,8 +38,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert ## Was nicht bearbeitet wird - Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, - duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später). - `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`. + duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global ]`: ihren Inhalt im + Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe + `lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in + `skipped_nodes`. - Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder öffnen und speichern, Divi 5 wandelt sie dabei um. - Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und @@ -49,4 +51,5 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert `lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel). -Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. +Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten; globale Module ebenfalls nicht (über die +Schnittstelle rendert Divi sie nicht) – `notes` nennt sie mit ihrer ID. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 28791d1..a41a34b 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -21,14 +21,14 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, -`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`. +`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`. -Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter +Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen („wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“ schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten -Tools warnt die Seite (Kontextkosten in claude.ai). Änderungen gelten ab der nächsten Verbindung. +Tools warnt die Seite (Kontextkosten im Assistenten). Änderungen gelten ab der nächsten Verbindung. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): @@ -36,12 +36,16 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, +`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). -Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. +Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Sie arbeiten auch auf Einträgen +der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`. Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, -`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. +`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`. + +Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf. ## Rückmeldungen diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md index 7ae6496..87734f8 100644 --- a/docs/guides/workflows.md +++ b/docs/guides/workflows.md @@ -22,6 +22,13 @@ 4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`). +## Bibliothek und globale Module + +Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert` +einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`. +Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen: +`lit-guide` topic `divi-library`. + ## Text auf vielen Seiten ersetzen `lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach @@ -29,7 +36,8 @@ Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. Ersetzt wird nur sichtbarer Text (keine Links, Stile, Code oder dynamischen Inhalte); in reinen Textfeldern (Titel, Button-Texte) werden Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht -geändert, sondern in `skipped_nodes` genannt – dem Nutzer sagen, dass er sie in der Divi-Bibliothek ändern muss. +geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic +`divi-library`). ## Menüs pflegen diff --git a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md new file mode 100644 index 0000000..9ff14d8 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md @@ -0,0 +1,222 @@ +# Etappe 3 – Entscheidungen der Planung (Entwurf) + +Vorbereitung von Etappe 3 („Library & Theme Builder“), 2026-09-26/27. Grundlage: Spec §3, §4.3, §5, §7, §10, §12, §15; Entscheidungen Etappe 2 (D1–D12); Rulings 2b (S1–S52), Praxistest und Security-Audit 2b. Quelle: Divi 5.2.1 (`wp-content/themes/Divi`, `style.css:4`), Pfade relativ zu `themes/Divi/includes/`. Live geprüft nur lesend (DDEV, `wp eval-file`). Entwurf – noch nicht vom Controller freigegeben. Kosten, falls falsch, jeweils am Ende. + +## 1. Verifizierte Fakten + +### 1.1 Divi Library (`et_pb_layout`) + +- F1: Post-Typ `et_pb_layout` (`ET_BUILDER_LAYOUT_POST_TYPE`, `builder/post/type/Layout.php:4`), `capability_type post`, `map_meta_cap`, `show_ui true`, `show_in_rest false`, unterstützt `revisions` (`Layout.php:79`). Taxonomien `layout_category`, `layout_tag`, `layout_pack`, `layout_type`, `module_width`, `scope`, `layout_location` (`Layout.php:83-90`). Live: `assign_terms` = `edit_posts`, verwalten = `manage_categories`; `layout_location` ist lokal nicht registriert. +- F2: Anlegen (Divi 4-Weg, von Divi 5 weiter benutzt): `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) setzt Terme `scope` (`global`/`not_global`), `layout_type` (`layout`/`section`/`row`/`module`), `module_width` (`regular`/`fullwidth`), Kategorien/Tags und Meta `_et_pb_row_layout` (Zeilen), `_et_pb_module_type` (Module), `_et_pb_excluded_global_options = "[]"` (globale Module), `_et_pb_built_for_post_type`, `_et_pb_template_type`; `et_pb_create_layout()` (`:738-800`) speichert immer mit `post_status publish` über `wp_insert_post` (also mit kses). Im REST-/CLI-Kontext ist `et_pb_submit_layout` nicht geladen (live: `function_exists` = false; geladen nur über `builder/shortcode-framework.php:6`). +- F3: Divi 5 legt Library-Einträge über die Builder-Route `POST divi/v1/divi-library/create-item` an (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:743-749`, `DiviLibraryController::create_item` `DiviLibraryController.php:606-757`): D4-Konvertierung, Preset-/Farb-/Variablen-Import, dann `et_pb_submit_layout` (`:715`). Recht: `edit_posts` + `et_pb_is_allowed('divi_library')` + `('save_library')` (`:958-966`). Inhalt wird vorher mit `SavingUtility::prepare_content_for_db` bereinigt (`:980`; `VisualBuilder/Saving/SavingUtility.php:230-254`: ohne `unfiltered_html` wird die Seite komplett geparst und neu serialisiert). Der Versuch, danach `_et_pb_use_divi_5` zu setzen, liest `$new_layout_meta['layout_id']` aus einem JSON-String (`:715-720`) – auf dieses Flag ist bei Library-Einträgen kein Verlass. +- F4: Library-Einträge speichern ihren Inhalt in `post_content` in einem `divi/placeholder`-Wrapper (`DiviLibraryController.php:2395, 2461-2466`). +- F5: „Global“ = Term `scope=global`; Rückfall für importierte D4-Module: `_et_pb_module_type` und `_et_pb_excluded_global_options` gesetzt (`builder-5/server/Packages/GlobalLayout/GlobalLayout.php:34-61`, `GlobalLayout::is_global_layout_template()`). +- F6: Instanz auf einer Seite = Platzhalterblock `divi/global-layout` mit `{"globalModule":"","blockName":"divi/","localAttrs":{…}}` und den Kindern als `innerBlocks` (Schnappschuss). Belegt durch die D4→D5-Konvertierung (`builder-5/server/Packages/Conversion/Conversion.php:1454-1479`) und die Serialisierung des Builders (`builder-5/visual-builder/build/serialized-post.js`, Filter `divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested`: alle Attribute außer `globalModule`, `localAttrsMap`, `localChildren`, `globalParent`, `blockName`, `globalModuleTemplate` wandern nach `localAttrs`). Kinder innerhalb einer globalen Vorlage tragen `globalParent` (`Conversion.php:1491-1494`). Modul-Metadaten: `divi/global-layout` hat keine Attribute (`builder-5/server/_all_modules_metadata.php:18550-18557`). +- F7: **Ausgabe per Verweis.** Beim Rendern ersetzt `BlockParser` jeden `divi/global-layout` durch den aktuellen Inhalt des Library-Posts (`builder-5/server/FrontEnd/BlockParser/BlockParser.php:838-884`; `BlockParserStore::get_global_layout_content`, `BlockParserStore.php:1113-1330`). Aus dem Schnappschuss `localAttrs` werden nur die Attribute übernommen, die die Vorlage in `localAttrsMap` als „nicht synchronisiert“ führt (`apply_local_attrs_filtering`, `BlockParserStore.php:348`); die Kinder aus der Seite nur, wenn die Vorlage `localChildren: true` hat (`:1307-1311`). Die Seitenkopie ist also ein Schnappschuss, maßgeblich ist der Library-Post. +- F8: **Keine Expansion bei REST-POST.** `BlockParser` überspringt die Expansion bei `MigrationContext`, beim Speichern und bei jedem REST-Request mit POST/PUT/PATCH (`BlockParser.php:827-831`, `_is_rest_update_request` `:690-701`). Unsere MCP-Aufrufe sind REST-POST – `lit/page-render-summary` (D5, `the_content` im Prozess) sieht globale Module daher **nicht**. +- F9: Bearbeitet man eine globale Instanz im Visual Builder, speichert der Builder sofort den Library-Post über `POST divi/v1/divi-library/save` (`visual-builder/build/global-layouts.js`; `DiviLibraryController::save` `:1439-1447` = `wp_update_post(wp_slash(…))`, Inhalt vorher `prepare_content_for_db`). Recht: `edit_posts` + `et_pb_is_allowed('edit_global_library')` (`:1494-1500`) – **ohne** `edit_post` auf den Eintrag. +- F10: Verschachtelte globale Module sind verboten: beim Öffnen einer globalen Vorlage im Builder werden innere `divi/global-layout` „entglobalisiert“ (`VisualBuilder/SettingsData/SettingsDataCallbacks.php:739-743`, `Framework/Portability/PortabilityPost.php:3521-3605` `maybe_deglobalize_nested_global_modules`: Platzhalter → `blockName` mit `localAttrs` als Attribute, `globalParent` entfernt). Divi selbst liest `globalModule` aus Markup per String-Scan, weil `parse_blocks()` damit nicht zuverlässig umgeht (`PortabilityPost.php:3510`, `BlockParserStore.php:1137-1184`, `FrontEnd/Assets/DetectFeature.php:349-359`). +- F11: Library-REST-Routen (`RESTRegistration.php:671-775`): `divi-library` (Liste), `/item`, `/update-terms`, `/update-item`, `/convert-item`, `/split-item`, `/load`, `/create-item`, `/save`, `/upload-image`, `/item-location` (löscht trotz Namen Posts, `DiviLibraryController.php:1197-1221`). Alle nonce-geschützt, nur für den Builder. +- F12: Rechte im Divi-Rollen-Editor: `et_pb_is_allowed()` (`builder/core.php:1128-1162`); Library-Admin braucht `divi_library` + `save_library` (`builder/functions.php:340-346`), globale Einträge zusätzlich `edit_global_library` (`functions.php:366-372`; im Builder `core.php:4248-4262`). `theme_builder` und `read_dynamic_content_custom_fields` sind „gefährlich“: für andere Rollen als Administrator standardmäßig **aus** (`core.php:1138-1156`), Multisite-Super-Admins immer an. Live: keine Rollen-Einstellungen gespeichert (`et_pb_role_settings` leer) → Standardwerte. +- F13: Live-Bestand: 0 `et_pb_layout`, 0 `et_template`, 0 `et_theme_builder`, 0 TB-Layouts, kein Inhalt mit `globalModule` (Probe `s1.php`). **Echte Library-/TB-Daten fehlen vollständig.** + +### 1.2 Theme Builder + +- F14: Post-Typen `et_theme_builder`, `et_template`, `et_header_layout`, `et_body_layout`, `et_footer_layout` (`builder/frontend-builder/theme-builder/theme-builder.php:7-23, 89-252`), alle `capability_type post`, `show_ui false`, `show_in_rest false`; Layouts unterstützen `revisions` (`:184, 216, 248`), Vorlagen nicht (`:152`). +- F15: Live- und Entwurfsstand: `et_theme_builder_get_theme_builder_post_id($live)` sucht den TB-Post mit Status `publish` (live) bzw. `auto-draft` (Entwurf des TB-Editors), ohne Meta `_et_library_theme_builder` (`theme-builder.php:378-413`). Die Vorlagen hängen als mehrfaches Meta `_et_template` am TB-Post, mit Sicherung in der Option `et_tb_templates_backup_` während des Speicherns (`:424-441`; `api.php:223-243`). +- F16: Vorlage (`et_template`) = Meta `_et_default`, `_et_enabled`, `_et_autogenerated_title`, `_et_{header,body,footer}_layout_id`, `_et_{…}_layout_enabled`, `_et_{…}_layout_global`, `_et_library_item_id`, mehrfach `_et_use_on`/`_et_exclude_from` (`theme-builder.php:471-521`). `override` = Layout-ID ≠ 0 oder Bereich deaktiviert (`:502`). +- F17: `et_theme_builder_store_template($tb_id, $template, $allow_default)` (`theme-builder.php:1463-1586`): `wp_die()` ohne `edit_others_posts` (`:1464-1466`); neue Vorlagen sofort `publish` (`:1515-1521`); Layout-IDs nur übernommen, wenn der passende Typ **und `publish`** (`:1486-1496`); Bedingungen `sanitize_text_field`, dedupliziert (`:1565-1583`). Die Funktion hängt die Vorlage **nicht** an den TB-Post – das macht nur `et_theme_builder_api_save` (`api.php:223-243`). +- F18: `et_theme_builder_insert_layout($options)` (`theme-builder.php:1643-1667`): `wp_die()` ohne `edit_others_posts`, `wp_insert_post` mit `post_status publish` (kses!), Term `layout_type=layout`, `et_builder_enable_for_post()` (setzt `_et_pb_use_builder`, `core.php:4438-4449`). +- F19: Divis TB-Speichern läuft über admin-ajax (`wp_ajax_et_theme_builder_api_save`, `api.php:160-273`: `et_builder_security_check('theme_builder', 'edit_others_posts', …)`), ersetzt die gesamte `_et_template`-Liste des TB-Posts (`:233-240`), trasht beim Live-Speichern den Entwurf und markiert unreferenzierte Vorlagen/Layouts mit `_et_theme_builder_marked_as_unused`; nach 7 Tagen werden sie gelöscht (max. 50 je Lauf, `theme-builder.php:591-678`). Danach `et_theme_builder_clear_wp_cache('all')` und `ET_Core_PageResource::remove_static_resources('all','all')` (`api.php:249-254`). +- F20: `api.php` wird nur bei `is_admin() || wp_doing_ajax()` geladen (`theme-builder.php:50-61`); im REST-Kontext fehlen die `et_theme_builder_api_*`-Funktionen. Vorhanden (live geprüft): `et_theme_builder_insert_layout`, `et_theme_builder_store_template`, `et_theme_builder_get_theme_builder_templates`, `et_theme_builder_get_template_settings_options`, `et_theme_builder_get_flat_template_settings_options`, `et_theme_builder_clear_wp_cache`. Divi 5 hat keine eigenen TB-REST-Routen (nur `ThemeBuilder/Layout.php`, `ThemeBuilder.php` für die Ausgabe). +- F21: Bedingungs-IDs mit Trenner `:` (`theme-builder.php:28`): `singular:post_type::all` (`:703-710`), `archive:post_type:` (nur `post` oder `has_archive`, `:724-734`), `singular:post_type::id:` (`:805-813`), `singular:post_type::children:id:` (`:822-829`), `homepage` (`:717`), `search`, `404` (`:1030-1039`), Archiv-, Taxonomie-, Autor-, Datumsvarianten (`:873-987`). Jede hat einen `validate`-Callback (`template-setting-validations.php:13-325`); `et_theme_builder_get_flat_template_settings_options()` (`:1143`) liefert die flache Liste. +- F22: Die Layouts des Theme Builders werden bei der Ausgabe live gelesen (`et_theme_builder_get_template_layouts`, `theme-builder.php:1295-1406`); Änderungen an einem aktiven Layout sind sofort öffentlich. Divi-Module mit `appearance.themeBuilderLayouts` (z. B. `divi/post-content`, `_all_modules_metadata.php:25503`; `fullwidth-post-content` `:14455`) werden in anderen Bereichen nicht gerendert (`Packages/ModuleLibrary/ModuleRegistration.php:509-527`). +- F23: Divi-Recht für TB-Layouts im Builder: `et_pb_is_allowed('theme_builder')` (`functions.php:2353`) plus die WP-Rechte; TB-Admin-AJAX zusätzlich `edit_others_posts`. + +### 1.3 Dynamic Content, Pods, Loop + +- F24: `DynamicContentOptions::get_options($post_id, $context)` sammelt alles über den Filter `divi_module_dynamic_content_options` (`builder-5/server/Packages/Module/Layout/Components/DynamicContent/DynamicContentOptions.php:85-133`). Live für Seite 831: 57 Optionen, davon 34 `loop_*`, `post_meta_key` (Felder `before|after|select_meta_key|meta_key|date_format|custom_date_format|enable_html`) und je Post-Typ `post_link_url_` mit Feld `post_id`. +- F25: Meta-Schlüssel-Liste: „meistgenutzte“ Meta-Schlüssel der ganzen Website plus ACF-Felder – nur mit `read_dynamic_content_custom_fields` oder im Kontext `display` (`DynamicContentOptionPostMetaKey.php:80-140`). Pods kennt Divi nicht. +- F26: Ausgabe von `custom_meta_`: Wert über `get_post_meta`/ACF, Filter `divi_module_dynamic_content_resolved_custom_meta_value` (`DynamicContentOptionPostMetaKey.php:404`), dann `esc_html` ohne `enable_html` (`:452-457`). Arrays von Strings werden mit „, “ verbunden; Beziehungs-/Dateifelder ohne ACF bleiben rohe IDs. +- F27: Interner Link als natives Dynamic Content: `post_link_url_` mit `settings.post_id`, Ausgabe `esc_url(get_permalink($id))` zur Renderzeit (`DynamicContentOptionCustomPostLinkUrl.php:34, 186-217`) – entwurfssicher, nach dem Veröffentlichen automatisch die schöne URL. +- F28: Loop Builder: `module.advanced.loop.desktop.value.{enable, queryType, …}` (`Packages/Module/Options/Loop/LoopUtils.php:135-145`), Werte im Loop über `loop_*`-Optionen; die Expansion läuft nur beim Rendern (`BlockParser.php:895-905`). + +### 1.4 Stand im Plugin + +- F29: `ContentSchema::EXCLUDED` schließt `et_pb_layout`, `et_theme_builder`, `et_template` und die drei TB-Layout-Typen aus (`src/Integrations/WordPress/Schema/ContentSchema.php:19-24`); `PostGuard::editable` liefert dafür `not_found`. Die Divi-Werkzeuge erreichen Library und TB heute nicht. +- F30: Opak sind Blöcke mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen (`src/Integrations/Divi/BlockTree/Parser.php:56`); `Block::setAttrs` wirft dort (`Block.php:91-96`). +- F31: Parser-Kosten (live, 8 Divi-Posts, 28 KB, 20 Durchläufe): Parse + NodeIndex ≈ 2 ms je 100 KB Inhalt (Probe `s3.php`). +- F32: Divi 5.2.1 registriert **keine** WordPress Abilities (`grep wp_register_ability|wp_abilities_api_init` im Theme: 0 Treffer) und enthält kein MCP. + +## 2. Empfohlene Entscheidungen + +- E1 – Ein Zugang für alle Divi-Posts. Die bestehenden Divi-Werkzeuge (`lit/divi-outline`, `-node-get`, `-insert`, `-update`, `-move`, `-delete`, `-duplicate-node`) akzeptieren zusätzlich IDs von `et_pb_layout` und `et_header/body/footer_layout` (Spec §5.1). Umsetzung: eine `DiviTarget`-Richtlinie neben `PostGuard` (Typ, Recht, Status-Regel je Typ), die `DiviPageLoader` verwendet; `ContentSchema::EXCLUDED` bleibt (die Content-Tools sehen diese Typen weiter nicht). – Grund: keine zweite Werkzeugfamilie, gleiche Pipeline (D7, S39). – Kosten, falls falsch: Rechteprüfung pro Typ an einer Stelle falsch → Zugriff auf Library/TB ohne Divi-Recht; abgefangen durch eigene Tests je Typ. +- E2 – Globale Module werden im Library-Post bearbeitet, nie in der Seitenkopie. Auf Seiten bleiben `divi/global-layout`-Knoten opak (D3); `lit/divi-node-get`/`-outline` liefern für sie `global_id`, `block_name` und einen Hinweis „mit post_id= bearbeiten“ (S19-Teil 2). Schreiben in den Library-Post wirkt auf alle Instanzen (F7); die Antwort nennt die betroffenen Seiten (Verwendungssuche über `"globalModule":""` in `post_content`, wie Divi F10) und verlangt bei mehr als einer veröffentlichten Instanz `confirm: true`. `localAttrs`/Kinder-Schnappschüsse in Seiten werden nie angefasst; das Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz ist nicht Teil von Etappe 3. – Grund: Divis Ausgabe ist per Verweis (F7), der Builder macht es genauso (F9). – Kosten: Instanz-spezifische Abweichungen nur im Builder. +- E3 – Rechte (strenger als Divi, nie lockerer). Library lesen: `edit_posts` + `divi_library`; speichern/anlegen: + `save_library`; einfügen aus der Library: + `add_library`; globalen Eintrag bearbeiten: + `edit_global_library` **und** `edit_post` auf den Eintrag (Divi prüft das nicht, F9). Theme Builder lesen und schreiben: `edit_others_posts` + `et_pb_is_allowed('theme_builder')` (F19, F23; Spec §4.5 um die Divi-Rolle ergänzt). Dynamic-Content-Meta-Liste nur mit `read_dynamic_content_custom_fields` (F25). Weil `et_theme_builder_store_template`/`insert_layout` bei fehlendem Recht `wp_die()` aufrufen (F17, F18), prüft das Plugin vorher selbst. `et_pb_is_allowed` steckt in einem Adapter (E15). – Kosten: ein Redakteur mit WP-Rechten, aber ohne Divi-Rolle, bekommt `forbidden` (gewollt, wie im Builder). +- E4 – Library-Einträge legt das Plugin selbst an (kein `et_pb_submit_layout`, keine Builder-Route): `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Library-Entwürfe, F2), Terme/Meta wie F2, danach Inhalt über `DiviSaver::saveNew` im kses-Fenster (D7, D11-Nachtrag), eingewickelt in `divi/placeholder` (F4), Flags wie `DiviFlags`. Ist ein Schritt fehlerhaft, wird der Entwurf endgültig gelöscht (wie `lit/content-create`). – Grund: `et_pb_submit_layout` ist im REST-Kontext nicht geladen und speichert mit kses (F2); die Builder-Route braucht Nonce und importiert Presets/Farben (F3). – Kosten: Divi ändert Terme/Meta → Einträge fehlen in der Library-Ansicht; abgesichert durch Fixture-Vergleich mit echten Builder-Einträgen (E13) und Kompatibilitätsprüfung (E15). +- E5 – `lit/library-insert` als Kopie oder globale Instanz. Kopie: Inhalt ohne `divi/placeholder`, verschachtelte globale Platzhalter entglobalisiert wie Divi (F10), eingefügt wie `lit/divi-insert` (StructureRules, AdminLabel). Globale Instanz: das Plugin erzeugt `` ohne `localAttrs` (voll synchronisiert). Prüfungen: Eintrag existiert, `publish`, `scope=global`, `read_post`, Wurzeltyp passt an die Zielposition, kein globales Modul in einem globalen Library-Post (F10). – Kosten: falls der Builder leere `localAttrs`/fehlende `innerBlocks` anders erwartet, sieht der Mensch im Builder evtl. eine andere Darstellung – vor dem Bau mit einer Builder-Fixture verifizieren (E13). +- E6 – Werte aus gespeicherten Inhalten (Library → Seite, Seite → Seite, Seite → Library) gelten als „gespeichert, nicht vom Client“ wie bei `lit/divi-duplicate-node`, werden aber beim Wechsel des Posts erneut geprüft: für Benutzer ohne `unfiltered_html` werden Knoten mit Builder-only-Feldern (S7/S26: `*.decoration.attributes`, `*.advanced.html`, `*.css.*`) und Code-Modul abgelehnt („nur im Divi-Builder“). Alles andere geht unverändert durch den BlockTree (byte-identisch). – Grund: Audit M1 – der Weg über eine andere Post-ID darf die Builder-only-Sperre nicht umgehen; Autor und damit Divis `before/after`-Filterung ändern sich mit dem Ziel-Post (R2-Befund 1). – Kosten: Redakteure können admin-gebaute Abschnitte mit eigenem CSS nicht kopieren. +- E7 – Theme Builder: Vorlagen und Layouts über Divis vorhandene Funktionen (`et_theme_builder_insert_layout` mit leerem Inhalt, Inhalt danach über `DiviSaver::saveNew`; `et_theme_builder_store_template`), Zuordnung zum Live-TB-Post selbst (`add_post_meta($live_id, '_et_template', …)`, gleiche Reihenfolge wie F19 inkl. Backup-Option), danach Cache wie F19. Nie der Entwurfs-TB-Post, nie `et_theme_builder_trash_draft_and_unused_posts()`. Existiert ein TB-Entwurf (`auto-draft`, F15), wird abgelehnt mit Hinweis „Theme Builder ist in Bearbeitung – dort speichern oder verwerfen“ (sonst überschreibt ein späteres Speichern im TB-Editor die Zuordnung und die neue Vorlage wird nach 7 Tagen gelöscht, F19). Bedingungen nur aus `et_theme_builder_get_flat_template_settings_options()` plus deren `validate`-Callback, Post-IDs mit `read_post`. – Kosten: Divi ändert die TB-Speicherung → Vorlage fehlt im TB-Editor; Kompatibilitätsprüfung + E2E. +- E8 – Neue TB-Vorlagen werden **deaktiviert** angelegt (`_et_enabled=0`); aktivieren ist eine explizite Aktion in `lit/tb-assign` (`enabled: true`, `confirm: true`, Liste der betroffenen Bedingungen). Standardvorlage (`_et_default`) nie über MCP. – Grund: Spec §2 Prinzip 5 (Status bleibt, Veröffentlichen explizit); TB-Posts kennen keinen Entwurf (F17, F18, F22). – Kosten: ein Aufruf mehr. Offen als Frage F-1. +- E9 – Werkzeugliste Etappe 3 und Exposition. Direkt: nur `lit/library-insert` (Spec §4.3) → 22 direkte Tools, unter der Warnschwelle 25 des Tools-Tabs. Indirekt: `lit/library-list` (mit `id` → Detail; ersetzt `lit/library-get`), `lit/library-save`, `lit/divi-node-copy`, `lit/library-make-global`, `lit/library-find-similar`, `lit/divi-dynamic-sources`, `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`. Gruppen: Library und Theme Builder als eigene Gruppen („nur lesen“ wirkt wie bei Divi, Audit M2: `lit/library-insert` und `lit/divi-node-copy` folgen zusätzlich dem Modus von `lit/divi-insert`). Destruktiv-Hinweis: `lit/library-make-global` (ersetzt Knoten), `lit/tb-assign` (öffentliche Wirkung). – Kosten: `lit/library-get` aus der Spec entfällt als eigener Name (Spec §5.7 anpassen). +- E10 – `lit/library-make-global`: legt einen globalen Eintrag aus dem Knoten an (E4) und ersetzt den Knoten durch eine Instanz (E5), in einem Aufruf, alles oder nichts (Library-Post wird bei Fehlschlag der Seite gelöscht). „Gleichartige Vorkommen ersetzen“ nur für **inhaltsgleiche** Knoten (Inhalts-Fingerabdruck, E11), mit Probelauf (Standard) und `confirm: true` je Seite mit `tree_rev` (Muster D10). Strukturgleiche Knoten mit anderen Texten werden nur gemeldet, nie ersetzt (Spec §3.3 „fast gleich“ = später). – Kosten: Kunde muss ähnliche Abschnitte vorher angleichen. +- E11 – `lit/library-find-similar`: zwei Fingerabdrücke je Abschnitt/Zeile: Struktur (Blocknamen und Tiefe, Spalten-`flexType`, Preset-IDs) und Inhalt (kanonische Attribute ohne Admin-Label, Bytes der Kinder). Suche seitenweise mit `cursor`, Post-Typ-Filter, höchstens 200 Posts je Aufruf, nur Posts, die der Benutzer bearbeiten darf; kein Index/Meta (F31: ≈1 s Parse für 1000 Seiten à 50 KB, Datenbanklast dominiert). Die Schreibwarnung „ähnliche Abschnitte“ (Spec §5.5, D6) vergleicht nur mit Library-Einträgen (klein) und verweist sonst auf das Tool. – Kosten: auf sehr großen Websites mehrere Aufrufe; bei Bedarf später ein Cache nach `tree_rev`. +- E12 – Dynamic Content. `lit/divi-dynamic-sources(post_type)` liefert `DynamicContentOptions::get_options` für einen Beispiel-Post des Typs (sonst `0`), ohne `loop_*` (S36), dazu Pods-Felder des Typs aus `PodsFieldSource` mit Speicherart und Eignung (Text-ähnlich = `{{field:}}`; Beziehung/Datei = „zeigt IDs“, F26). Beim Schreiben in einen TB-Body-Layout gilt als Kontext-Post-Typ der Typ aus den Bedingungen der zugeordneten Vorlage (`singular:post_type::…`); ohne Zuordnung Warnung. Loop Builder bleibt lesbar/opak (Werte unverändert), wird nicht erzeugt. – Kosten: CPT-Archive mit Pods-Feldern in Schleifen erst später. +- E13 – Übernahmen aus Etappe 2: (a) `{{link:}}` → `post_link_url_` mit `settings.post_id` (F27), Prüfung `read_post`, kein Papierkorb; nur in URL-Feldern; (b) `lit/guide` mit `topic: "all"` (alle Themen in einem Aufruf, Größenlimit); (c) S19: `lit/divi-design-system` mit Filter (`section`: colors/variables/presets/fonts, `module` für Presets) und `global_id` in `node-get`; (d) `lit/page-render-summary` meldet globale Platzhalter als Hinweis „globales Modul – im Auszug nicht enthalten“ (F8) und erhält für Library-/TB-IDs keinen Zugang. – Kosten: gering. +- E14 – Reihenfolge und Aufteilung. **3a „Library“**: Fixtures (E16) → Übernahmen E13 → `DiviTarget`/Rechte (E1, E3) → `lit/library-list` → Divi-Werkzeuge auf Library-Posts inkl. globaler (E2) → `lit/library-save` (E4) → `lit/library-insert` (E5) → `lit/divi-node-copy` (E6) → `lit/divi-dynamic-sources` (E12) → Guides/Prompt `globale-module-vorschlagen`. **3b „Theme Builder & Konsolidierung“**: `lit/tb-list` → TB-Layouts in den Divi-Werkzeugen (inkl. `themeBuilderLayouts`-Regel F22 in StructureRules) → `lit/tb-create-template` (E7, E8) → `lit/tb-assign` → `lit/library-find-similar` + Warnung (E11) → `lit/library-make-global` (E10) → Guide `theme-builder`, Prompt `custom-post-type-vorlage`. Abschluss jeder Teiletappe wie 2b (Review je Task, Security-Audit, Praxistest). – Kosten: find-similar/make-global kommen spät; sie hängen an stabilen Library-Grundlagen. +- E15 – Divi-Zugriffe über Adapter mit Live-Kompatibilitätsprüfung (siehe Abschnitt 5): je Divi-Bereich eine Klasse (`DiviModules` und `DesignSystemReader` gibt es schon; neu `DiviLibraryStore`, `DiviThemeBuilder`, `DiviRoles`), die Funktions-/Klassen-Existenz und Signaturen prüft und bei Abweichung `unsupported` statt Fatal liefert; `lit/site-status` zeigt den Befund. – Kosten: etwas mehr Code; spart Ausfälle nach Divi-Updates. +- E16 – Teststrategie. (1) Unit: Platzhalter-Erkennung, Fingerabdrücke, Bedingungs-Validierung, Instanz-Markup, Entglobalisieren gegen Divis `maybe_deglobalize_nested_global_modules` (gleiche Eingaben, gleiches Ergebnis). (2) Fixtures aus echten Builder-Daten: Jan-Niclas baut vor Beginn in DDEV im Visual Builder nach Checkliste globales Modul, globale Zeile mit einem nicht synchronisierten Attribut, globaler Abschnitt, normaler Library-Abschnitt, je eine Seite mit Instanzen, eine TB-Vorlage „Person“ mit Header/Body/Footer und Bedingungen; ein neues Lese-Skript `tests/Integration/divi-export-library.php` exportiert `post_content`, Terme, Meta und TB-Meta als `builder-library-*.html` / `builder-tb-*.json` (wie D12). Roundtrip-Test läuft automatisch mit. (3) DDEV-Smoke `divi-library-smoke.sh` / `tb-smoke.sh` (mit Aufräumen). (4) Manuelle Abnahme: vom Plugin erzeugte Einträge/Instanzen/Vorlagen im Builder und TB-Editor öffnen und speichern. – Kosten: ohne Fixtures (F13) bleiben E4/E5/E7 Vermutung; daher Fixtures als erster Schritt und Bedingung für den Bau. +- E17 – Sicherheit neuer Schreibwege: jeder Weg nach `post_content` von Library- und TB-Posts geht durch Composer/AttrWriter/TreeEditor → `DiviSaver` (D7, S39); Titel, Terme und Meta über WordPress mit üblicher Filterung (`sanitize_text_field`, `wp_set_object_terms` mit bestehenden Term-IDs; neue Terme nur mit `manage_categories`); keine Builder-Route und kein `et_pb_create_layout` mit Client-Inhalt; TB-Bedingungen nur aus Divis Liste; Revision vor jeder Änderung (Library und TB-Layouts unterstützen Revisionen, F1/F14), Protokolleintrag je Post; nach Library-/TB-Änderungen `remove_static_resources('all','all',true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5). – Kosten: Review-Pflicht wie D7. + +## 3. Fragen an den Benutzer (nach Wichtigkeit) + +1. **Sollen neue Theme-Builder-Vorlagen sofort wirken?** Divi kennt für Vorlagen keinen Entwurf. + - (a) Deaktiviert anlegen, Aktivieren nur ausdrücklich mit `lit/tb-assign` und Bestätigung. **Empfehlung.** + - (b) Sofort aktiv, wie im TB-Editor. + - (c) Aktiv nur auf Bedingungen für einzelne IDs, alles Breitere (ganzer Post-Typ, Archiv) deaktiviert. +2. **Wie frei darf der KI-Assistent globale Module ändern?** Jede Änderung wirkt sofort auf allen Seiten, auch veröffentlichten. + - (a) Erlaubt mit Rechten wie im Builder, `confirm: true` und Liste der betroffenen Seiten. **Empfehlung.** + - (b) Nur Administratoren (`unfiltered_html`/`manage_options`). + - (c) Globale Einträge nur lesen, Änderungen nur im Builder. +3. **Was zuerst: Library oder Theme Builder?** + - (a) 3a Library (globale Module, Einfügen, Kopieren), dann 3b Theme Builder. **Empfehlung** (technisch sauberere Grundlage). + - (b) Theme Builder zuerst, weil ein Kunde CPT-Vorlagen braucht (Library-Teil danach). +4. **Pods-Beziehungs- und Dateifelder im Theme Builder:** Divi zeigt davon nur IDs. + - (a) In Etappe 3 nur Text-ähnliche Pods-Felder anbieten, andere als „nicht geeignet“ melden. **Empfehlung.** + - (b) Das Plugin formatiert Pods-Werte beim Rendern (Filter `divi_module_dynamic_content_resolved_custom_meta_value`) – wirkt dann auf der Website, auch ohne MCP. + - (c) Ganz auf später verschieben. +5. **Woher kommen die echten Fixtures?** + - (a) Du baust sie lokal in DDEV nach Checkliste, ein Skript exportiert sie. **Empfehlung.** + - (b) Export von einer Kundenseite (anonymisiert). + - (c) Nur aus Divi-Code abgeleitet (riskant, F13). + +## 4. Risiken + +| Risiko | Gegenmaßnahme | +|---|---| +| Keine echten Library-/TB-Daten lokal (F13); Instanz-Format (`localAttrs`, `innerBlocks`, `globalModule` an der Vorlagenwurzel) nur aus Code/minifiziertem JS abgeleitet | Fixtures zuerst (E16, Frage 5), Builder-Abnahme | +| Späteres Speichern im TB-Editor überschreibt unsere `_et_template`-Zuordnung, Vorlage wird nach 7 Tagen gelöscht (F19) | Ablehnung bei TB-Entwurf (E7), `lit/tb-list` zeigt unzugeordnete Vorlagen | +| Globale Änderung wirkt sofort auf veröffentlichte Seiten | `confirm`, Seitenliste, Revision des Library-Posts (E2, Frage 2) | +| `page-render-summary` sieht globale Module nicht (F8) | Hinweis im Auszug (E13d) | +| `et_theme_builder_*` rufen `wp_die()` statt Fehler zu liefern (F17, F18) | Vorabprüfung der Rechte, Adapter (E3, E15) | +| Divi-Interna ändern sich (Library-Meta, TB-Meta, Instanz-Format); ET kündigt ein offizielles MCP an | Adapter + Kompatibilitätsprüfung (E15), Fixture-Tests je Divi-Update | +| Builder-only-Werte gelangen über Kopie in fremde Posts (Audit M1-Muster) | E6 | +| Pods mit Tabellen-Speicherung: `get_post_meta` findet nichts | `lit/divi-dynamic-sources` meldet solche Felder als ungeeignet (E12) | +| Rollen-Editor: „gefährliche“ Divi-Rechte standardmäßig nur für Administratoren | E3, klare `forbidden`-Meldung mit Hinweis auf Divi → Rollen-Editor | +| Große Websites: find-similar lädt viel `post_content` | Cursor, 200 Posts je Aufruf (E11) | +| Loop Builder / Archive nicht abgedeckt | bewusst später (E12) | + +## 5. Schnittstellen-Einordnung + +Stufen: (a) öffentliche/dokumentierte API (WordPress-Kern, dokumentierte Divi-Hooks), (b) Divis Builder-REST/AJAX (`divi/v1`, admin-ajax, nonce-geschützt), (c) interne PHP-Klassen/Funktionen/Meta-Konventionen, (d) selbst nachgebildete interne Formate. Empfehlung: **B** = behalten, **W** = wechseln auf offizielleren Weg, **A** = in Adapter mit Live-Kompatibilitätsprüfung (E15). + +Zur Offizialität: Elegant Themes dokumentiert Hooks und die Modul-API (dev.elegantthemes.com, u. a. `divi_module_dynamic_content_options`, `divi_module_dynamic_content_resolved_custom_meta_value`, `divi_module_dynamic_content_custom_meta_options`, `divi_module_library_register_module_attrs`), aber keine API für externen oder KI-Zugriff. Der Divi AI Agent läuft im Builder (im Browser, AI-Wallet-Abrechnung); in den Kommentaren zur Ankündigung schreibt ET, ein „official MCP is on the way“. Divi registriert keine WordPress Abilities (F32). + +### 5.1 Bereits genutzt (Etappe 2a/2b) + +| Schnittstelle | Stelle im Plugin | Stufe | Risiko bei Divi-Updates | Empfehlung | +|---|---|---|---|---| +| WordPress-Kern: `wp_update_post`, Revisionen, kses an/aus, Meta-API | `DiviSaver`, `RevisionPoint` | a | gering | B | +| Block-Delimiter-Format (``, WP_Block_Parser-Muster) | `BlockTree/Parser` | a (WP-Format) | gering | B | +| Divi-Blocknamen und Attribut-JSON (`module.decoration…`, `flexType`, `divi/placeholder`) | `Composer`, `AttrWriter`, `ColumnWidths` | d (Schema aus c) | mittel | B – Pfade werden gegen den Live-Katalog validiert; Builder-Fixtures | +| `ModuleRegistration::get_all_core_modules_metadata()`, `get_default_attrs()` | `Schema/DiviModules` | c | mittel | A (Adapter existiert; Prüfung in `lit/site-status` ergänzen) | +| `GlobalData::get_global_colors/-variables`, `GlobalPreset::get_data`, Option `et_divi` | `Design/DesignSystemReader`, `MediaUsageFinder` | c (REST-Pendant `divi/v1/global-data/*` = b) | mittel | A; kein Wechsel auf b (nonce, Builder-Kontext) | +| `DynamicContentOptions::get_options()` | `Pages/DynamicContentMap` | c; Füllweg über dokumentierten Filter `divi_module_dynamic_content_options` (a); REST `divi/v1/dynamic-content/options` (b) | mittel | A (Rückfall auf Standard-Map existiert) | +| `$variable(...)$`-Syntax, gcid/gvid, Dynamic-Content-JSON | `Compose/VariableRef`, `Placeholders` | d | mittel | B + Prüfung gegen Divis Regex `ET_THEME_BUILDER_DYNAMIC_CONTENT_REGEX_D5` (`theme-builder.php:36`) im Kompatibilitätstest | +| Meta `_et_pb_use_divi_5`, `_et_pb_use_builder`, `_et_pb_show_page_creation` | `Pages/DiviFlags`, `PostPresenter` | d (nachgebildet aus `SyncToServerController`) | mittel | B; `_et_pb_use_builder` könnte über `et_builder_enable_for_post()` (c) laufen – kein Gewinn | +| `ET_Core_PageResource::remove_static_resources()` | `DiviSaver` | c (dokumentierter Hook `et_core_page_resource_post_id_before_clear` deutet auf stabile API) | gering | B | +| `et_builder_enabled_for_post_type()` | `ContentSchema` | c | gering | B | +| `ET_BUILDER_PRODUCT_VERSION` | `DiviModules` | c | gering | B | +| Divis `wp_insert_post_data`-Filter (AttributeSecurity, HtmlSecurity) als Verteidigung in der Tiefe | Verhalten, kein Aufruf | c | mittel | B (nie entfernen, S7) | + +### 5.2 Geplant (Etappe 3) + +| Schnittstelle | Zweck | Stufe | Risiko | Empfehlung | +|---|---|---|---|---| +| Post-Typen, Taxonomien, Meta der Library (F1, F2) | Liste, Anlegen (E4) | c/d | mittel | A (`DiviLibraryStore`), Vergleich mit Builder-Fixtures | +| `GlobalLayout::is_global_layout_template()` | „global?“ | c | gering | B (Divis eigene Definition statt Term-Logik) | +| `divi/global-layout`-Platzhalter (`globalModule`, `blockName`, `localAttrs`) | Instanzen (E5, E10) | d | hoch | A + Fixtures; nur minimale Form ohne `localAttrs` erzeugen | +| Entglobalisieren wie `PortabilityPost::maybe_deglobalize_nested_global_modules` | Kopie einfügen (E5) | c | mittel | Eigene Umsetzung (d), per Test gegen Divis Methode geprüft (Aufruf direkt wäre möglich, arbeitet aber auf Strings ohne unsere Byte-Garantie) | +| Builder-Routen `divi/v1/divi-library/*` (F11) | – | b | mittel | nicht nutzen: Nonce, fehlende Pro-Eintrag-Rechte (F9), `create-item` hängt an nicht geladenem Code (F2, F3) | +| `et_pb_is_allowed()` | Divi-Rollen (E3) | c | gering | A (`DiviRoles`) | +| `et_theme_builder_insert_layout`, `et_theme_builder_store_template` | TB schreiben (E7) | c (in Spec genannt) | mittel | B über Adapter `DiviThemeBuilder`, Rechte vorab (wp_die) | +| `et_theme_builder_get_theme_builder_post_id`, `…_get_theme_builder_templates`, `…_get_template` | TB lesen | c | gering | B/A | +| `_et_template`-Zuordnung + Backup-Option (F15, F19) | Vorlage aktivieren | d (admin-ajax-Weg `et_theme_builder_api_save` = b, im REST-Kontext nicht geladen) | mittel | A; bei Divi-Update prüfen, ob `et_theme_builder_api_save` die Logik ändert | +| `et_theme_builder_get_flat_template_settings_options()` + `validate`-Callbacks | Bedingungen (E7) | c | gering | B | +| `et_theme_builder_clear_wp_cache`, `remove_static_resources('all','all',true)` | Cache | c | gering | B | +| `DynamicContentOptions::get_options()` je Post-Typ | `lit/divi-dynamic-sources` | c (Filter a) | mittel | A (vorhandener `DynamicContentMap`) | +| `post_link_url_` + `post_id` | `{{link:ID}}` (E13a) | d (Format), Registry c | gering | B – Name über die Registry prüfen, nicht fest verdrahten | +| `divi_module_dynamic_content_resolved_custom_meta_value` | nur falls Frage 4 = (b) | a | gering | B (dokumentiert) | +| `appearance.themeBuilderLayouts` aus den Modul-Metadaten | StructureRules für TB-Bereiche | c | gering | B (aus Live-Katalog) | +| Loop Builder (`module.advanced.loop`) | – | d | hoch | nicht erzeugen (E12) | +| WordPress Abilities von Divi | – | – | – | gibt es nicht (F32); beobachten, ob ETs angekündigtes MCP eine offizielle Schnittstelle bringt, die c/d-Teile ersetzen kann | + +Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantthemes.com/blog/theme-releases/ai-agent), [Divi Dev Docs – Filter](https://dev.elegantthemes.com/docs/hooks/php/filters/), [Divi Developers](https://www.elegantthemes.com/developers/). + + +## 5. Antworten des Benutzers (2026-09-27) +- F1 Theme-Builder-Vorlagen: standardmäßig deaktiviert angelegt; Aktivieren über ein eigenes Tool (explizit). +- F2 Globale Module bearbeiten: erlaubt mit Trockenlauf (Liste der betroffenen Seiten) und `confirm`, vorher Revision/Sicherung des Bibliothekseintrags. +- F3 Reihenfolge: erst 3a Bibliothek, dann 3b Theme Builder. +- F4 Pods-Beziehungs-/Dateifelder: als dynamische Quelle nur textartige Felder anbieten; andere mit Hinweis auslassen. +- F5 Testdaten: selbst erzeugen („Probier es selbst“) – über Divis eigene Speicherwege (Builder-REST-Controller/Services im Prozess), als „[Fixture] …“ markiert, danach als Fixtures ins Plugin exportiert. Pods ist lokal deaktiviert; Aktivierung für 3b beim Benutzer anfragen. + +## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md) + +- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` = + vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`, + ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen + `page--globale-instanzen.html`. +- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload); + `LibraryContent::load()` vereinheitlicht das vor jeder Übernahme (undurchsichtige Blöcke bleiben unberührt). +- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`); + entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10). `lit/library-save` + mit `scope: "normal"` übernimmt enthaltene Instanzen ebenfalls als Verweise. `mode: "global"` nur für + globale Einträge mit genau einer Wurzel ohne globale Module (P11: nie verschachtelte Instanzen). +- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch fremde Blöcke, + Freitext, nicht lesbare Blöcke, kses-instabilen Rich-Text (P3) und `$variable`-Verweise (Ablehnung, wenn + `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund: R2-Befund 1 + (before/after nach dem Autor des Ziel-Posts). Lesbare globale Instanzen werden geprüft (localAttrs und + Schnappschuss), nicht abgelehnt (P15). Strenger als Divi; sichtbare Ablehnung. URL-Werte: `url($v) === + trim($v)` oder relativer Link ohne Schema (P17/P18) – `kontakt/`, `/pfad`, `#anker` bleiben kopierbar. +- Builder-only erweitert (S7-Gate, gilt auch für Seiten): `decoration.interactions` (P16 – der Effekt + „Attribut“ setzt im Browser beliebige Attribute, z. B. `onclick`; „Cookie“ setzt Cookies) und + `lottie.innerContent` (P19 – `src` lädt fremdes JSON, lottie-web wertet Ausdrücke aus). Offen für + Etappe 4: Divi-Presets können beides tragen; Preset-Schreibwege brauchen dasselbe Gate. +- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`); + `scope: "global"` braucht zusätzlich `edit_global_library` (P7). Ohne das Divi-Recht „Divi-Bibliothek“ + (oder mit deaktivierter Gruppe, P9) ist jede Bibliotheks-ID `not_found`, byte-gleich mit einer fehlenden ID + (P12); ohne `edit_post` auf dem Eintrag `forbidden` ohne Titel. `lit/library-list` listet nur Einträge mit + `edit_post` und liest höchstens 500 Zeilen je Aufruf. +- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe + „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf + Bibliothekseinträge (`ExposurePolicy::groupWritable`). `lit-library-insert` und `lit/divi-node-copy` folgen + zusätzlich dem Zustand von `lit/divi-insert` (M2). P13 in 3a nur dokumentiert (Guide „Rechte“, Grenze): bei + deaktivierter Gruppe oder ohne `divi_library` nennen Outline/node-get/Render-Auszug einer Seite noch die ID + globaler Module; der Eintrag selbst bleibt `not_found`. Vollständiges Ausblenden folgt in 3b. +- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte + Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die + betroffenen Seiten. P8a: die Bestätigung wird abgelehnt, wenn der Benutzer einen Eintrag mit Status + `publish`, `private` oder `future`, der den Eintrag zeigt, nicht bearbeiten darf (auch TB-Layouts und andere + Bibliothekseinträge); Entwürfe blockieren nicht. Der Probelauf nennt solche IDs vorab in `blocked_ids` + (P14). Revisionen aus: abgelehnt ohne `unfiltered_html` (die Sicherung liefe durch kses) oder bei + deaktiviertem Papierkorb (P9); scheitert das Speichern nach einer Sicherung, nennt der Fehler `backup_id` + (P14). Cache-Leerung für alle Seiten und den Theme Builder. +- P1: `lit/divi-replace-text` speichert normale Bibliothekseinträge über `DiviWriter::commit()`; globale nie + (die Ziele tragen kein `confirm`) – das Ergebnis nennt die betroffenen Seiten und verweist auf + `lit-divi-update` mit `confirm`. +- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5). +- E13a: `{{link:}}` nur in URL-Feldern; Ziel muss existieren, `read_post` bestehen, nicht Papierkorb, + `auto-draft` oder `inherit` sein – Anhänge sind damit ausgeschlossen (P10). Entwürfe und private Ziele + rendert Divi für Besucher als `?page_id=` (nur die ID wird sichtbar, Preflight). +- F4/E12: `lit/divi-dynamic-sources` listet nur textartige Felder; Beziehungs-/Dateifelder („nur IDs“), + Tabellen-Speicher und unbekannte Feldtypen (P9) stehen unter `omitted`; Auswahlfelder zeigen den + gespeicherten Wert, nicht die Beschriftung. Der Beispiel-Post ist nur einer, den der Benutzer bearbeiten + darf. +- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status` + (`problems` je Komponente). +- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt. +- Tests: die Unit-Suite läuft mit `memory_limit` 512M (`phpunit.xml.dist` und `composer test`), weil der + Divi-Katalog beim Standard von 128M nicht mehr in den Speicher passt. +- Server-Instructions: Zeile „Divi-Bibliothek: …“ nur bei aktiver, beschreibbarer Gruppe; MCP-Prompts gibt es + noch nicht, der Ablauf „globale Module vorschlagen“ steht im Guide `divi-library` (E14). diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md b/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md new file mode 100644 index 0000000..29bf872 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md @@ -0,0 +1,124 @@ +# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek) + +Automatisch geprüft sind bereits: + +- alle Unit-Tests (`composer test`) und Lint (`composer lint`); +- `divi-library-smoke.sh` (neu) und die fünf bisherigen Integrationsskripte `divi-write-smoke.sh`, + `divi-read-smoke.sh`, `mcp-smoke.sh`, `wp-basis-smoke.sh` und `media-admin-smoke.sh` (alle in + `tests/Integration/`). + +`divi-library-smoke.sh` prüft über den OAuth-Endpunkt, nur mit temporären Daten, die es danach wieder löscht: + +- Werkzeugliste: `lit-library-insert` direkt, `lit/library-list`, `lit/library-save`, `lit/divi-node-copy` und + `lit/divi-dynamic-sources` indirekt, insgesamt 22 direkte Tools; +- Speichern als normaler und als globaler Eintrag: Inhalt byte-gleich mit Divis eigenen Einträgen, dieselben + Begriffe (`scope`, `layout_type`, `module_width`) und Divi-Metadaten, keine Divi-Kennzeichen, Quellseite + unverändert; `lit/library-list` zeigt beide Einträge; +- Einfügen als globale Instanz und als Kopie: Bytes wie beim Visual Builder, keine Windows-Zeilenumbrüche; +- globale Änderung: Probelauf mit `confirm_required` und den betroffenen Seiten, `confirm` ändert den Eintrag + mit Revision, die Bytes der Seiten bleiben gleich (sie verweisen nur auf den Eintrag); +- ein Redakteur, der eine private Seite mit der Instanz nicht bearbeiten darf: Probelauf mit `blocked_ids`, + Bestätigung abgelehnt (`forbidden`), nichts gespeichert; +- Knoten kopieren zwischen Seiten, auch die Ablehnung einer veralteten `source_tree_rev`; +- `{{link:ID}}` im Button: gespeichert als Divis „Seite Link“ (`post_link_url_page`), ein Entwurf als Ziel + erscheint nur als `?page_id=…`; in Textfeldern abgelehnt; +- ein Redakteur ohne `unfiltered_html`: Interaktionen, Lottie-Quelle und eigenes HTML werden von + `lit/library-save`, `lit-library-insert` (Kopie) und `lit/divi-node-copy` abgelehnt, harmlose Knoten gehen; +- Gruppe „Divi-Bibliothek“ deaktiviert: Bibliothekseinträge sind in den Divi-Lesetools `not_found`, die + Bibliothekstools verschwinden; +- `lit-guide` mit Thema „all“, `lit-site-status` ohne Befunde bei Divi, `lit/divi-dynamic-sources` für Seiten. + +Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen +als kurze Notiz zurückmelden; sie werden Folgeaufgaben. + +## Vorbereitung + +- [ ] DDEV läuft, Divi 5.2.1 ist aktiv, LIT MCP zeigt Version 0.4.0 (Plugins-Seite). +- [ ] Unter Einstellungen → LIT MCP → Tools stehen die Gruppen „Divi“ und „Divi-Bibliothek“ auf ihrem + Standard (nicht „nur lesen“, nicht deaktiviert). +- [ ] Den KI-Assistenten neu verbinden, damit er die neuen Tools und Hinweise lädt. In einer neuen + Unterhaltung ist `lit-library-insert` sichtbar. +- [ ] Nach einem Lauf von `divi-library-smoke.sh` ist nichts übrig: unter Seiten → Alle Seiten und unter + Divi → Divi-Bibliothek nach „LIT Smoke“ suchen (jeweils auch im Papierkorb) – keine Treffer. +- [ ] Zwei Entwurfsseiten mit dem Divi Builder anlegen, z. B. „Abnahme 3a – Seite A“ und „Abnahme 3a – Seite B“, + jede mit mindestens einem Abschnitt, einer Zeile und einem Textmodul. Die „[Fixture]“- und + „[Test]“-Inhalte nicht verwenden und nicht ändern. + +## 1. Vom Plugin angelegte Bibliothekseinträge im Visual Builder + +- [ ] Den KI-Assistenten bitten, einen Abschnitt von Seite A als normalen Eintrag und ein Textmodul von + Seite A als globalen Eintrag in der Divi-Bibliothek zu speichern (Titel mit „Abnahme 3a“). +- [ ] Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit dem richtigen Typ (Abschnitt bzw. Modul), + der Modul-Eintrag ist als global gekennzeichnet. +- [ ] Beide Einträge in der Bibliothek mit dem Visual Builder öffnen: der Inhalt stimmt mit dem Knoten auf + Seite A überein, der Builder meldet keine Fehler. +- [ ] Seite A im Visual Builder öffnen: sie ist unverändert (Speichern in die Bibliothek ändert die Quelle nicht). +- [ ] Im Visual Builder von Seite B „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen + sich einfügen. Danach die Seite ohne Speichern verlassen. + +## 2. Vom Plugin eingefügte globale Instanz und Kopie + +- [ ] Den KI-Assistenten bitten, den globalen Eintrag mit `lit-library-insert` in Seite B einzufügen (als + Instanz) und den normalen Eintrag als Kopie. +- [ ] Seite B im Visual Builder öffnen: das Modul ist als global markiert (Divis Kennzeichnung für globale + Module) und zeigt den Inhalt aus der Bibliothek; der kopierte Abschnitt ist nicht global. +- [ ] Die Kopie im Builder ändern und speichern: der Bibliothekseintrag und Seite A bleiben unverändert. +- [ ] Das globale Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und + überall, wo die Instanz steht. +- [ ] Vorschau von Seite B: der Inhalt des globalen Moduls wird angezeigt. +- [ ] Hinweis: `lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie + nicht) und nennt sie unter `notes`; das ist erwartet. + +## 3. Globales Modul über den KI-Assistenten ändern (Probelauf und Bestätigung) + +- [ ] Den KI-Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst den Probelauf zeigen: + welche Seiten betroffen sind (hier mindestens Seite B), und nachfragen, bevor er etwas speichert. +- [ ] Nach der Nachfrage ablehnen: der Eintrag bleibt unverändert (in der Divi-Bibliothek prüfen). +- [ ] Erneut bitten und zustimmen: der Assistent speichert mit Bestätigung (`confirm`). +- [ ] Vorschau aller betroffenen Seiten zeigt den neuen Text; die Seiten selbst wurden nicht neu gespeichert + (unter „Revisionen“ der Seite B keine neue Revision). +- [ ] Beim Bibliothekseintrag ist unter „Revisionen“ der Stand vor der Änderung sichtbar. +- [ ] Unter Einstellungen → LIT MCP → Protokoll steht die Änderung des Eintrags. +- [ ] Den Eintrag im Visual Builder öffnen: er lässt sich weiter bearbeiten und speichern. + +## 4. Knoten zwischen Seiten kopieren + +- [ ] Den KI-Assistenten bitten, einen Abschnitt von Seite A mit `lit/divi-node-copy` auf Seite B zu kopieren. +- [ ] Seite B im Builder öffnen, den kopierten Abschnitt ändern und speichern: Seite A bleibt unverändert. + +## 5. Interner Link + +- [ ] Den KI-Assistenten bitten, auf Seite B einen Button mit `{{link:}}` einzufügen. +- [ ] Im Builder zeigt das Link-Feld des Buttons dynamischen Inhalt „Seite Link“ mit Seite A. +- [ ] Solange Seite A ein Entwurf ist, verlinkt der Button in der Vorschau auf `?page_id=`. +- [ ] Seite A veröffentlichen: der Button verlinkt auf die schöne Adresse. Danach Seite A wieder als Entwurf + speichern. + +## 6. Rechte und die Gruppe „Divi-Bibliothek“ + +- [ ] Einstellungen → LIT MCP → Tools: die Gruppe „Divi-Bibliothek“ ist sichtbar, mit `lit-library-insert`, + `lit/library-list` und `lit/library-save`. +- [ ] Gruppe auf „nur lesen“: nach neuer Verbindung fehlt `lit-library-insert` in der Werkzeugliste; + Bibliothekseinträge lassen sich lesen (`lit-divi-outline`), Änderungen daran werden abgelehnt, und + `lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle wird ebenfalls abgelehnt. + Hinweis: Steht dagegen die Gruppe „Divi“ auf „nur lesen“, bleibt `lit-library-insert` in der Liste, lehnt + aber jedes Einfügen ab (`unsupported`, „Divi-Änderungen sind … abgeschaltet“) – das ist so gewollt, kein + Fehler. +- [ ] Gruppe deaktivieren: nach neuer Verbindung fehlen die Bibliothekstools; eine Bibliotheks-ID gilt in + `lit-divi-outline` als nicht vorhanden. Seiten lassen sich weiter lesen und bearbeiten. +- [ ] Die Gruppe wieder auf ihren Standard stellen und neu verbinden. +- [ ] Optional mit einem Redakteurs-Konto: unter Einstellungen → LIT MCP → Zugriff die Rolle „Redakteur“ + freigeben. Im KI-Assistenten die Verbindung trennen und neu verbinden; bei der Anmeldung (OAuth) mit dem + Redakteurs-Konto einloggen – am einfachsten in einem privaten Browserfenster, in dem niemand bei + WordPress angemeldet ist. Dann in Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente + bearbeiten“ abschalten und den Assistenten ein globales Modul ändern lassen: Ablehnung mit Hinweis auf + den Rollen-Editor. Danach das Recht wieder einschalten, die Freigabe der Rolle zurücknehmen und den + Assistenten wieder als Administrator verbinden. +- [ ] `lit-site-status`: bei Divi keine Einträge unter `problems`. + +## Aufräumen + +- [ ] Die Einträge „Abnahme 3a …“ in der Divi-Bibliothek und die Seiten A und B löschen (auch aus dem Papierkorb). +- [ ] Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an; + sie nicht von Hand löschen. +- [ ] Optional: die Einträge der Abnahme im Protokoll-Tab über „Protokoll leeren“ entfernen. diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md new file mode 100644 index 0000000..7c49e63 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md @@ -0,0 +1,111 @@ +# Etappe 3a – Entscheidungen (Rulings), Befunde und offene Punkte + +Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3a-security-audit.md. + +## Offen für spätere Etappen +- Etappe 3b: vollständiges Ausblenden von Bibliotheks-IDs bei deaktivierter Gruppe (P13); gleiche Zielregeln für rohe post_link_url_* wie für {{link:}} (P10); Theme Builder, find-similar, make-global. +- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren. +- Etappe U: try/catch um Divi-Aufrufe (P11), einheitliche Fehlercodes der Tools-Tab-Sperren, doppelte Helfer, Test-Feinschliff, „blurb ist leer“-Warnung, Rollen-Option in divi-write-smoke. +- Restrisiko (Low, P25): sehr ungewöhnliche Schreibweisen globaler Verweise auf Seiten, die der Benutzer nicht bearbeiten darf, können der Verwendungssuche entgehen (setzt Bearbeitungsrechte an der verweisenden Seite voraus). + +## Pre-flight rulings +Pre-flight (opus): 0 Critical, 8 Important, 19 Minor, 4 Info; fixtures match byte for byte; {{link:ID}} to draft/private shows only ?page_id=. Full tables: preflight.md. +- Ruling P1 (I-1, → Task 9): lit/divi-replace-text on library entries goes through DiviWriter::commit() (dry run/confirm for global, site-wide cache clear, no Divi flags on library posts) or refuses library targets — implementer picks the variant that reuses commit(); F2 binding — cost if wrong: none +- Ruling P2 (I-2, → Task 11): rebuild the 3 test rows with Divi's \u0022 escaping and assert the parsed block is not opaque — cost if wrong: none +- Ruling P3 (I-3, → Task 11): StoredNodeGuard refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, opaque blocks and rich-text HTML that is not kses-stable when moving stored nodes between posts — cost if wrong: editors copy less (visible refusal) +- Ruling P4 (I-4, → Task 2): only the link row uses URL mode in the new placeholder test — cost if wrong: none +- Ruling P5 (I-5, → Task 13): lit-library-insert reads the entry via LibraryAccess (edit_post) — cost if wrong: none +- Ruling P6 (I-6, → Task 12): lit/library-save reuses NewDraft (partial_write + recordPartial on failed rollback) — cost if wrong: none +- Ruling P7 (I-7, → Task 12): scope "global" additionally requires edit_global_library (Divi's rule, F12) — cost if wrong: none +- Ruling P8 (I-8, → Task 9): a confirmed global edit is refused when the user cannot edit_post one of the published pages that render the entry (stricter than Divi; security first) — cost if wrong: editors with partial rights can't change shared modules +- Ruling P9 (minors): confirm stays honour-based (D10); a disabled library group hides library IDs from the Divi read tools; lit/divi-node-copy follows lit/divi-insert exposure (E9); refuse the backup path when a user without unfiltered_html edits with revisions off or trash disabled (kses backup loses bytes); output schema gains affected, affected_count, backup_id; unknown dynamic field types are omitted (F4); dispatch notes per preflight (OutlineTest anchor ×3, >120-char lines, dead variable) — cost if wrong: small +- Note: common.md fixed — PostChangeResult was renamed to PostSaveFollowUp in 2b W3 + +## Progress +- Task 1: dispatched (sonnet, BASE cfc81dd) +- Task 1: complete (404f0f3, trailer normalized; 2-line config change verified by controller; 1597 tests) +- Task 2: dispatched (opus, BASE 404f0f3; P4, link read_post check) +- Task 2: implementer done (788943e; 1609 tests); note T16 docs: attachments refused by {{link:}} +- Task 2: review (opus): Approved; live: read_post enforced (private/foreign draft/trash refused, uniform message), ID forms refused, URL-only on all paths, no leak +- Ruling P10 (T2 minors → final wave): full-form post_link_url_* uses LinkTargets (same target rules as {{link:}}; trashed/unattached attachment consistent) + one shared ID parser; attachments refused by {{link:}} documented in T16 — cost if wrong: none +- Task 2: complete (404f0f3..788943e) +- Tasks 3–5: dispatched as one batch (sonnet, BASE 788943e, one commit per task) +- Tasks 3–5: review (sonnet): all Approved (858aa6a, a3a8070, b389f98 after trailer normalization; 1617 tests); global id in outline/node-get = library post ID only (accepted, like GlobalUsages); minors → final wave: preset-cap note only when presets requested; Outline duplicates Block::shortName prefix strip +- Tasks 3, 4, 5: complete (788943e..b389f98) +- Tasks 6–7: dispatched (opus, BASE b389f98, one commit per task) +- Tasks 6–7: implementer done (ce1ad7c, 2700fd6; 1646 tests); T7 deviations: unifyLineBreaks skips opaque blocks; deglobalize keeps invalid blockName instance opaque +- Tasks 6–7: review (opus): both Approved; byte fidelity independently verified incl. deglobalize == Divi's own PortabilityPost output; deviations accepted +- Ruling P11 (T6/T7 minors): → T12/T13 dispatch: refuse opaque/global roots before GlobalInstance::of (no nested instances); → final wave: try/catch Throwable around Divi calls in DiviLibraryStore/DiviRoles (E15 never fatal), stdClass localAttrs, self-closing of() for created childless roots, replaceChild attached-check, isSpecialtyColumn by effectiveName +- Tasks 6, 7: complete (b389f98..2700fd6) +- Task 8: dispatched (opus, BASE 2700fd6; P9 library-group hiding hook) +- Task 8: implementer done (48ecc62; 1660 tests); P9 hook LibraryAccess $visible + ExposurePolicy::groupEnabled, wired as libraryVisible (GROUP 'divi-library', live with T10); T9 must merge: $libraryWritable param + LibraryAccess::GROUP already exist +- Task 8: review (opus): Approved; live with temp editor/author/contributor/subscriber + role-editor right withdrawn; P9 hook verified with real ExposurePolicy +- Ruling P12 (T8 I-1): users without the Divi library right at all (canList false) get PostGuard::notFound (no existence/type oracle, like P9); missing edit_post on an entry stays `forbidden` (project convention for pages) — fix round 1 now — cost if wrong: less helpful hint for users lacking the role right +- Ruling P13 (T8 M-1/M-2 → final wave / T10): with the library group hidden, page outline/node-get omit global_id and the render-summary hint; T10 keeps groupEnabled semantics (group mode only) +- Task 8: fix round 1 (P12; 681bb75; 1661 tests; live: logged-out 1058 vs 99999 byte-identical not_found) — verified by controller against report + live claim +- Task 8: complete (2700fd6..681bb75). Note for T10+: canList()/canRead() still return forbidden when called directly (fine for list tool — no ID oracle) +- Task 9: dispatched (opus, BASE 681bb75; F2, P1, P8, P9 backup refusal + schema) +- Task 9: implementer done (d66ad36; 1684 tests; divi-write smoke ok; live dry run on 1059 lists 1053, bytes unchanged); concerns: backup id not named on failure after backup; P8 published only +- Task 9: review (opus): live verified dry run/confirm/revision/reference semantics/P8 refusal/no flags; changes requested: I-1 P8 gap (private page: editor without rights changed what it shows), I-2 backup_id lost on failed save after backup +- Ruling P8a (amends P8): confirmed global edit requires edit_post on every affected post with status publish, private or future (incl. TB layouts/other library entries found by the query); drafts/pending listed in dry run but not blocking — private/future render or will render — cost if wrong: editors blocked more often +- Ruling P14: WriteFailed after a backup carries backup_id + hint (WriteFailed::withDetails); dry run warns with blocked_ids when P8 would refuse (M-1); plural fix (M-2); shared status-parameterised query (M-3); _prime_post_caches before P8 loop (M-4); M-5/M-6 → final wave/T16 +- Task 9: fix round 1 dispatched +- Task 9: fix round 1 (P8a, P14, M-1..M-4; 520a639; 1691 tests); re-review running +- Task 9: re-review clean (live: private/future block, draft not, blocked_ids in dry run, backup_id on failed save) +- Task 9: complete (681bb75..520a639) +- Task 10: dispatched (opus, BASE 520a639; P12, P13 if cheap, per-entry filtering) +- Task 10: implementer done (b7c7149; 1702 tests); P12 via item() for id; P13 → final wave +- Task 10: review (sonnet): Approved; live: group disabled → list unregistered + read tools not_found; nur lesen → list ok, writes forbidden; P12 id byte-identical; minor: MAX_SCAN cap silent → final wave +- Task 10: complete (520a639..b7c7149) +- Task 11: dispatched (opus, BASE b7c7149; P2, P3, stored $variable refs via VariableRef) +- Task 11: implementer done (f8275ad; 1741 tests; 30 malicious rows); concerns: readable global instances checked (localAttrs) not refused (per brief/preflight I-3); builder-written $variable refs often refused for editors (stricter than Divi); plain text kses-checked only when containing '<'; ValueCleaner::kindAt added +- Ruling P15: accept checking readable global instances (a reference renders existing library content; Divi lets library-right users insert them too); stricter $variable handling accepted (visible refusal, documented in T16) — cost if wrong: editors can't copy some builder nodes +- Task 11: review (opus, SEC): Approved (guard holds for P3/P15; Divi renders instances from published entries only, localAttrs+snapshot both checked). NEW Critical (pre-existing since 2b): module.decoration.interactions writable by editors → effect addAttribute onclick="…" → Divi's interactions script setAttribute → script on click; path is known in catalog so lit/divi-update can write it +- Ruling P16: add decoration.interactions to ValueCleaner::BUILDER_ONLY (S7 gate: nur im Divi-Builder für Benutzer ohne unfiltered_html) — closes main pipeline and StoredNodeGuard; separate commit with tests (full form, short form if any, stored guard, removal via null); also scan the catalog for other paths whose values Divi turns into attributes/HTML/script (e.g. other effect/attribute/script-like option groups) and gate them the same way, listing what was found — cost if wrong: editors can't set interactions via MCP +- Ruling P17 (T11 minors): guard URL rule `url($v) === trim($v)`; guard enforces VALUE_KEY key names and digits-only globalModule; STATE_KEYS reuses AttrWriter::STATES; >120-char test lines — in the same fix round +- Task 11: fix round 1 (P16 9d92808 interactions builder-only + scan: nothing else to gate; P17 e808832; 1748 tests; smoke ok) +- Ruling P18 (P17 too strict for relative links): StoredNodeGuard URL accept rule = url($v) === trim($v), OR the value has no scheme (no ':' before the first '/', '?', '#') and contains no whitespace, quotes, '<', '>' or control chars — relative links (kontakt/, /pfad, #anker, ?q=1) stay copyable, every scheme still must survive url() unchanged — cost if wrong: none (scheme-less values can't carry javascript:) +- Task 11: fix round 2 (P18; 16c6d59; 1750 tests); backslash hosts also refused, //host must survive url() +- Task 11: re-review: P16/P17/P18 verified live (interactions refused for editors incl. null removal + guard; 63 URL paths × entity-colon payloads safe; relative rule holds); NEW Important: divi/lottie lottie.innerContent.src → remote JSON loaded by lottie-web with expressions (eval) → script; editors could set it +- Ruling P19: divi/lottie lottie.innerContent (src and any animation-source keys) builder-only for users without unfiltered_html (same S7 gate as P16), in main pipeline and StoredNodeGuard; tests set/null/copy; guides list it — cost if wrong: editors can't set Lottie via MCP +- Task 11: fix round 3 (P19; 8fa285a; 1753 tests; smoke ok) verified by controller (pattern + tests) +- Note Etappe 4: Divi global presets can carry a Lottie src / interactions — preset writes (design system) must apply the same builder-only gates +- Task 11: complete (b7c7149..8fa285a) +- Task 12: dispatched (opus, BASE 8fa285a; P6, P7, P11, guard) +- Task 12: implementer done (5035092; 1783 tests; live: bytes equal Divi entry 1055, page unchanged, no flags); nested globals kept as references in normal entries (plan-conform, accepted) +- Task 12: review (opus): Approved; live: rights (edit_post source, publish_posts, divi_library/save_library, edit_global_library), guard refuses all builder-only kinds as editor, P11, P6 rollback + partial_write, terms/meta identical to Divi, exposure; bytes equal after CRLF→LF; minors → final wave: REASON_CREATE placement, test line length, partial_write hint for new entries +- Task 12: complete (8fa285a..5035092) +- Task 13: dispatched (opus, BASE 5035092; P5, P11, guard in copy mode, direct budget 22) +- Task 13: implementer done (7fe91c2; 1799 tests; smoke ok; live: 1059/1058 instance and 1055 copy byte-identical to fixture, probes cleaned) +- Task 13: review (opus): Approved; live: P5 (author vs admin/private/fixture forbidden, missing not_found), P11 incl. instance into global entry refused by shapeViolation, copy-mode guard refusals as editor, exposure 22 direct; minors → final wave (test line length, multi-root unit test, "zu bearbeiten" wording) +- Task 13: complete (5035092..7fe91c2) +- NEW (pre-existing, Important): editor saves via any Divi write tool rewrite untouched

→

(some content_save_pre filter still active despite kses removal); no CHANGED_ON_SAVE; tree_rev may mismatch stored content → investigation dispatched (opus) in parallel with Task 14 +- Task 14: dispatched (opus, BASE 7fe91c2); parallel: editor-rewrite investigation (read-only on code) +- Task 14: implementer done (6f6c691; 1814 tests; smoke ok); source_tree_rev required check; not live-probed +- Investigation editor rewrite: NOT reproducible (45 inputs, editor+admin, plain wp_update_post in kses window and end-to-end lit/divi-insert: byte-identical, tree_rev == Rev::of(stored)); likely tool-chain \u decoding in the reviewer's probe (known artifact); CHANGED_ON_SAVE detection works (wp_strip_custom_css_from_blocks + Divi AttributeSecurity both detected). Ruling P20: no kses-window change; final wave adds a file-based editor regression test and switches synthetic-divi-filters.html to \u003c escapes — cost if wrong: none +- Task 14: review (opus): Approved; live via registered ability: source access P5/P12, guard refusals as editor (7 kinds), P11, same-page byte identity, restore point, source/target rev locks, exposure matrix; minors → final wave: shared source-node helper (dup with LibrarySave), OUTLINE_HINT reuse, test line length, 'after'=>null in test +- Task 14: complete (7fe91c2..6f6c691) +- Task 15: dispatched (opus, BASE 6f6c691; F4, P9 unknown types, allow-map equivalence test) +- Task 15: implementer done (8808a7d; 1827 tests; allow map byte-identical JSON snapshot); concerns → final wave: {{link:}} hint for attachments vs P10, choice fields show value not label (document) +- Task 15: review (sonnet): Approved; allow-map snapshot test mutation-verified (non-vacuous); sample post only editable by user (implementer fixed brief leak); live page 26 sources +- Task 15: complete (6f6c691..8808a7d) +- Task 16: dispatched (opus, BASE 8808a7d; docs must describe actual T1–T15 behaviour, collected points in dispatch) +- Task 16: implementer done (2729e28; 1829 tests; v0.4.0); library line in instructions only when group writable; spec 'Claude' wording → final wave +- Task 16 review (sonnet) and Task 17 (opus, BASE 2729e28) dispatched in parallel (disjoint files: docs vs tests/Integration + abnahme note) +- Task 16: review (sonnet): Approved (docs verified against code: 22 direct, result keys, P8a/P9/P12/P16/P19/P3/P15–P18, link rules, version, topics, neutral wording); → final wave: wp-lit-mcp.php plugin header still says "mit OAuth für claude.ai"; internal spec "Claude" wording +- Task 16: complete (8808a7d..2729e28) +- Task 17: done (186f7af; 6 smokes ALL CHECKS PASSED; 1829 tests); Ruling P21: T17 (test script + acceptance note only) is reviewed within the final whole-branch review instead of a separate task review — cost if wrong: none +- Task 17: complete (2729e28..186f7af) +- Final: whole-branch review (opus) + security audit (opus) dispatched in parallel on cfc81dd..186f7af +- Final review (opus): ready with fixes; must: I-1 node-copy from library skips add_library (+ library-insert exposure), I-2 P13 guide vs code +- Security audit (opus): no Critical/High/Medium; Low: L1 DoS in P8a/usages (loads full posts), L2 referenced-but-not-global entry skips dry run/P8a (live), L3 library-list search covers content + decodable cursor → existence oracle for authors (live), L4 dry run lists id/status/type of unreadable posts, L5 = I-1, L6 TB layouts accepted on WP caps only; hardening: guard vs first globalModule id, confirm not bound to dry run, "0123" refs +- Ruling P22: final fix wave per final-fix-brief.md (must + audit L1–L6 + cheap review items); P13 full hiding and P10 → Etappe 3b; guide sentence fixed now — cost if wrong: none +- Final fix wave: 17/17 (1b42f9b..f1937ca; 1868 tests; 6 smokes ok); decisions: referenced normal entries get dry run/confirm (drafts listed, only live block, no edit_global_library needed); cursor counts shown entries only; divi/global-layout check via Divi module list; link hint dropped for internal types; divi-write-smoke roles-option restore same issue → U; scoped re-review dispatched +- Final fix re-review (opus): items 1–17 addressed (L1/L3/L4/L6/item 8 live-verified); F1 open: usage search misses Divi 4 shortcode global_module="ID" and JSON-escaped globalModule key/value (Divi renders them) → author edited an entry used only on such admin pages without dry run/P8a (live); minor: 'Eintrag, die' → 'den' +- Ruling P23: GlobalUsages finds references fail-closed: SQL prefilter candidates = content containing 'globalModule' OR 'global_module' OR a Divi block opener with '\\u' escapes; then PHP-parse candidates: Divi 4 shortcodes (global_module attr, intval) and Divi 5 openers (json_decode attrs → globalModule intval); an escaped opener whose globalModule can't be decoded counts as a possible reference (blocking if not editable); guide sentence adjusted; wp_block embedding documented as parity (Info) — cost if wrong: more pages listed/blocking on odd content +- Final fix round 1 (P23; fec5d9d, 8f87ec6; 1887 tests; smokes ok): candidate prefilter globalModule/global_module/escaped ASCII letter; UsageMatcher parses Divi 5 (json_decode) + Divi 4; undecodable escaped opener = possible reference; parse cap 5000 +- F1 re-check: live bypass fixed, prefilter not evadable; NEW G1 Divi 4 shortcode inside a Divi 5 attribute value (\u0022-escaped quotes) misparsed; G2 leading space / scientific notation cast by Divi (int); G3 global_module values casting to 0 render the NEWEST published layout (et_pb_load_global_module WP_Query p=0); _bin collation note +- Ruling P24: UsageMatcher scans decoded string values of every Divi 5 opener for Divi 4 shortcodes too; shortcode attrs parsed like WordPress (shortcode_parse_atts) and cast with (int) exactly like Divi; a global_module that casts to 0 counts as a reference to EVERY library entry (fail closed; rare); prefilter case-insensitive regardless of collation (LOWER() or explicit _ci COLLATE); guide wording without absolute claim; after this round remaining exotic forms are documented Low (breaker) — cost if wrong: coarse over-listing on odd content +- Final fix round 2 (P24; b6d791d; 1901 tests; smokes ok; live: shortcode in Divi 5 text value with ", leading space, abc, GLOBAL_MODULE found; other-ID control not found) +- Ruling P25 (breaker): accept after round 2 without another re-review — fail-closed rule in place, remaining exotic spellings need edit rights on the referencing page, documented as residual Low in the guide and rulings; G3 side effect (an invalid global_module on a real page makes every entry edit need confirm) accepted as fail-closed — cost if wrong: a crafted unusual spelling on a page the author can't edit escapes P8a (Low) +- Etappe 3a complete: cfc81dd..b6d791d diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md new file mode 100644 index 0000000..411de71 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md @@ -0,0 +1,210 @@ +# Etappe 3a (Divi-Bibliothek): security audit + +- **Scope:** branch etappe-3, cfc81dd..186f7af. Focus: what the per-task reviews covered least (object authorization, global edits, stored-content injection, disclosure, DoS). +- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1), single site, no editors/authors present, `DISALLOW_UNFILTERED_HTML` off. +- **Method:** read all new/changed `src/` files of the range plus the relevant Divi 5 render code (`BlockParser`, `BlockParserStore`, `GlobalLayout`, `DynamicContentOptions*`, `LinkUtils`, MultiView, Divi 4 `class-et-builder-element.php`), scanned all `script-library-*.js` for DOM sinks, and ran four self-cleaning `wp eval-file` probes (temp posts 1906–1914, temp user `litaudit_author`; all deleted, verified: 0 `LITAUDIT` posts, 0 postmeta/term rows for those IDs, no temp user, no probe files, `git status` clean). No [Fixture]/[Test] content was touched; no code was changed. + +**Result:** no Critical, High or Medium findings. The rulings P3/P5/P8a/P11/P12/P15–P19 hold. Six Low findings and three Info/hardening notes follow. + +--- + +## Findings (most severe first) + +### L1 – Low (DoS): the P8a check loads every live usage with full content, and each dry run scans `wp_posts` 3× + +**Where** +- `src/Integrations/Divi/Library/GlobalUsages.php:68-79` (`liveIds()`: no LIMIT). +- `src/Integrations/Divi/Library/GlobalEdits.php:158-171` (`_prime_post_caches($live, …)`, then `current_user_can('edit_post')` per ID). +- `GlobalUsages.php:29-47`: COUNT plus SELECT, each an unindexed `post_content LIKE '%…%'`. +- Also reached from `GlobalEdits::notice()` (every delete dry run) and `LibraryListAbility::detail()`. + +**Scenario** +1. A global module (newsletter box, CTA) sits in thousands of posts. That is a common Divi pattern. +2. Any user with library rights and `edit_post` on the entry calls a Divi tool on it without `confirm` (dry run), or `lit/library-list` with `id`. +3. `_prime_post_caches()` fetches `posts.*` for every live ID in one query, `post_content` included. At 5,000 posts × ~50 KB of Divi markup that is about 250 MB, which exceeds the usual `memory_limit` and ends the request in a fatal error. +4. Each call also runs 3 full-table LIKE scans (6 on confirm, because dry run and confirm both call `of()` and `blocked()`). A loop of calls ties up the database on large sites. +5. The effect stops when the requests stop, and it needs an authenticated MCP user. + +**Evidence:** by reading. The DDEV data set is too small to reproduce the memory limit. + +**Fix** +- Use one query: `SELECT ID, post_status, post_type, post_author, post_title … LIKE …`. Derive `count`, the first 50 items and the live list in PHP, instead of COUNT + SELECT + `liveIds()`. +- Evaluate `edit_post` in chunks of about 200 IDs, calling `_prime_post_caches()` per chunk and `clean_post_cache()` or `wp_cache_flush_runtime()` between chunks. +- Or fail closed: above a hard cap (e.g. 1,000 live usages), refuse the confirmed edit with a hint ("im Divi-Builder bzw. von einem Administrator bearbeiten"). + +### L2 – Low (global edits): "global" is decided by the scope term, but Divi renders every referenced entry, so edits to referenced non-global entries skip the dry run and P8a + +**Where** +- `src/Integrations/Divi/Library/GlobalEdits.php:71` and `:88-90`: `if (! $global) return LibraryItem`. +- `src/Integrations/Divi/Library/DiviLibraryStore.php:37` (`isGlobal()` → `GlobalLayout::is_global_layout_template()`, which checks the scope term, with a meta fallback for modules only). +- Divi: `BlockParser.php:838-880` renders any `divi/global-layout` whose `globalModule` names a *published `et_pb_layout`*. It checks only `post_type` and `post_status`, not the scope. + +**Scenario** +1. A live page that user U cannot edit holds an instance of library entry N, but N has no `global` scope term. This can happen when: + - the entry comes from an import (Divi's D4 fallback covers modules only, not sections or rows); + - the scope was changed later; + - the markup was written outside the Divi builder. +2. U has `edit_post` on N (as its author, or as an editor) and changes N with any Divi tool. +3. `GlobalEdits` treats N as a normal entry: no dry run, no `affected` list, no P8a check, no site-wide CSS clear. +4. The page U cannot edit now shows the changed content. + +**Evidence (live)** +- Temp entry 1910 (`scope = non_global`) and temp published page 1911 with ``. +- `isGlobal(1910) = false`, while `GlobalUsages::liveIds(1910) = [1911]`. +- The frontend HTTP render of 1911 shows the marker from 1910 (`LITAUDIT_NORMAL_ENTRY`). +- Both posts were deleted afterwards. + +**Fix:** in `GlobalEdits::prepare()` and `notice()`, apply the global rules (confirm, `affected`, P8a, `GlobalItem` scope) whenever `isGlobal()` is true **or** `GlobalUsages` finds at least one usage. For the check, one `LIMIT 1` query is enough. + +### L3 – Low (disclosure): the `lit/library-list` cursor is an oracle over the *content* of entries the caller may not read + +**Where** +- `src/Integrations/Divi/Library/LibraryListAbility.php:176-194`: rows the caller may not read still advance `$position`, and `next_cursor = Cursor::encode($position)` is `base64("o:N")`. +- `src/Integrations/Divi/Library/DiviLibraryStore.php:103`: `'s' => search` makes WP search title, excerpt **and content**, although the schema at `LibraryListAbility.php:68` says "Suche im Titel". + +**Scenario** +1. An author with the Divi library right creates two entries of their own that contain candidate words. +2. They then search with `limit: 1`. +3. The decoded cursor tells how many hidden entries match the search and sort before their own. That lets them guess words from other users' library content (codes, internal notes). +4. Plugin rule E3 / P12 is that authors may not read those entries: `lit/library-list id=…` returns `lit_forbidden`. +5. Mitigation: Divi's own Visual Builder library lists all entries to every user with the library right. Relative to Divi this is parity; relative to the plugin's own stricter rule it is a bypass. It is the same defect class that `PostGuard::ownPostsOnly()` already documents. + +**Evidence (live)** +- Temp author and 3 temp entries: an admin entry "aaa" containing `LITAUDITSECRET pin 4711`, and author entries "zzz1" and "zzz2" containing `LITAUDITSECRET`. +- As the author, `search=LITAUDITSECRET, limit=1` returns items `["LITAUDIT zzz1"]` with `next_cursor=bzoy` (= `o:2`, which reveals one hidden match). +- A direct read of the admin entry returns `lit_forbidden`. +- All temp data was deleted and the deletion verified. + +**Fix** +- Restrict the query in `find()`: + - add `'author' => PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE)` when it is non-null, so the cursor counts only rows the caller can see; + - set `'search_columns' => ['post_title']` (WP ≥ 6.2) to match the documented title-only search. +- Keep the per-row `canRead()` filter as defence in depth. + +### L4 – Low (disclosure): `affected`, `blocked_ids` and the P8a refusal expose IDs, status and type of posts the caller may not read + +**Where** +- `src/Integrations/Divi/Library/GlobalUsages.php:48-56`: title masked, but `post_id`, `status` and `type` are always returned. +- `src/Integrations/Divi/Library/GlobalEdits.php:184-203`: IDs in message and details. +- Also `lit/library-list id=…` (`affected`). + +**Scenario** +1. An author owns a global entry. An administrator used it on private pages and on other users' drafts. +2. The author's dry run lists those posts' IDs, their status (`private`, `draft`, `future`) and their post type. +3. Only the titles are hidden. + +**Evidence:** by reading. The masking applies to `post_title` only. + +**Fix** +- For rows without `read_post`, return only an aggregate: `hidden_count`, plus the count of blocking hidden posts. +- Name blocked IDs only where `read_post` holds; otherwise say "N Einträge, die dieses Konto nicht lesen darf". + +### L5 – Low (authorization): `lit/divi-node-copy` from a library entry into a page skips Divi's role option `add_library` + +**Where** +- `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:78-96`: the source goes through `DiviPageLoader::load(…, false)`, i.e. `LibraryAccess::item()` / `canRead()` (`edit_posts`, `divi_library`, `edit_post`). +- Compare `LibraryAccess::canInsert()` (`add_library`), which `lit/library-insert` requires. + +**Scenario** +1. The site owner turns off "Aus der Bibliothek hinzufügen" (add from library) for a role in Divi → Rollen-Editor. +2. A user of that role can still copy any node from an entry they can edit into a page, via `lit/divi-node-copy` with `source_post_id = `. +3. Security impact is small, because content still passes StoredNodeGuard. But it breaks E3 ("nie laxer als Divi", never laxer than Divi). + +**Evidence:** by reading. + +**Fix:** in `precheck()`, when the loaded source `isLibraryItem()`, additionally return `$access->canInsert()`. The target side is already covered by `canEdit()` / `save_library`. + +### L6 – Low (global edits): P8a accepts Theme Builder layouts on WordPress `edit_post` alone + +**Where** +- `src/Integrations/Divi/Library/GlobalEdits.php:158-171`. +- On DDEV, `et_header_layout`, `et_template`, `et_theme_builder` and `et_pb_layout` all use capability type `post` with `map_meta_cap`. So an editor has `edit_post` on the site header and footer layouts. +- Divi gates the Theme Builder with the role option `theme_builder`, which is off by default for every role but administrator. + +**Scenario** +1. A global module sits in the Theme Builder header, footer or body. +2. An editor with `edit_global_library` passes P8a, because they have `edit_post` on the TB layout. +3. The confirmed edit changes the site-wide header, although Divi does not let this editor use the Theme Builder. +4. Divi's own Visual Builder allows the same global edit from any page (parity with Divi). But P8a's intent, "changes only on posts this account may edit", is not met. + +**Evidence:** live, post type objects and caps (probe p1). The rest is by reading. + +**Fix:** in `blocked()`, also treat IDs whose post type is one of `et_header_layout`, `et_body_layout`, `et_footer_layout` or `et_template` as blocked unless `DiviRoles::allows('theme_builder')`. + +--- + +## Info / hardening + +### I1 – StoredNodeGuard trusts the instance's own `blockName` and `globalModule`; Divi renders the real entry + +**Where** +- `src/Integrations/Divi/Compose/StoredNodeGuard.php:90-91` and `:114-138`. Routing and the code-module check use `GlobalInstance::effectiveName()`, i.e. the stored `blockName`. +- `Json::decodeObject()`: the last duplicate key wins. + +**What Divi does** +- `BlockParser.php:838` takes the *first textual* `"globalModule":"(\d+)"`. That can be a nested one inside `localAttrs`, or the first of two duplicate keys. +- `BlockParserStore.php:1113ff` merges `localAttrs` (filtered by the entry's `localAttrsMap`) onto the entry's *actual* root block. + +**Consequence** +- A forged instance can be routed as `divi/text` (kses-checked richtext) while it is rendered onto a `divi/code` entry, or onto another entry than the guard saw. +- Exploiting this needs a forged instance in a source the user can edit. The user can already write that into their own page through core REST (parity, cf. 2b audit M1). The new part is that node-copy can carry it into a library entry (`et_pb_layout` has `show_in_rest = false`). + +**Hardening** +- Refuse instances whose JSON contains more than one `"globalModule"` occurrence. +- Resolve `globalModule` to a published `et_pb_layout` the user may read. +- Require its root block name to equal `blockName`, and route `localAttrs` by that real name. + +### I2 – Confirm is not bound to the dry run + +- `GlobalEdits.php:95-117`: between the dry run and `confirm: true`, the `affected` set can grow; the user agreed to N pages, and M are changed. +- Rights are safe, because P8a is recomputed at confirm (verified by reading; a live check exists from T9). +- Optional: accept `expected_affected_count` (or a hash of the affected IDs) and refuse on mismatch. + +### I3 – GlobalUsages misses non-canonical references Divi still renders + +- `"globalModule":"0123"` renders entry 123: Divi's `([0-9]+)` capture is cast to int by `get_post()`. +- The LIKE on `"globalModule":"123"` does not match it. +- Only someone who can edit the containing post can write it, so there is no attacker gain. +- Optional: LIKE on `'"globalModule":"%'` plus a PHP regex with `ltrim($id, '0')`. + +**Not a gap (checked live):** Divi 4 shortcode pages with `global_module=""`. GlobalUsages does not find them (probe p2/p3: `liveIds = []`), but Divi 5 does not render the D5 entry content there either. The frontend showed the page's own local D4 content, not the entry's. The plugin refuses to edit D4-format entries, so no unlisted page changes. + +--- + +## Areas checked and found sound + +- **IDOR via `library_id`:** `LibraryInsertAbility::item()` goes through `LibraryAccess::item(false)` (P5/P12): same `not_found` for non-library IDs and revisions, `forbidden` only for users with the library right. +- **IDOR via `source_post_id`:** PostGuard (`edit_post`, managed types only; `revision`, TB and library types excluded) or LibraryAccess, re-checked in `execute()`, with its own `source_tree_rev`. +- **IDOR via `post_id` in `$variable` settings / `{{link:ID}}`:** `select_post` → `read_post` (`DynamicContentRules.php:246-259`), re-checked by StoredNodeGuard through `canonical()` against the target post. Only `post_link_url_*` uses `select_post` in Divi 5.2.1. `{{link:}}` to `et_pb_layout` is refused by the registry, with a uniform message. +- **Divi role options:** + - `divi_library`, `save_library`, `add_library` and `edit_global_library` are combined correctly with `edit_posts`, `edit_post` and `publish_posts` in `LibraryAccess` (except L5). + - The P9 group gates (writable/visible) are applied in `item()`, `canEdit()`, `canSave()` and `canInsert()`. +- **Global edits through replace-text, node-copy into, insert into, and delete in a global entry:** + - All go through `DiviWriter::commit()` → `GlobalEdits` (shape, dry run, P8a, backup, `GlobalItem` scope). + - replace-text never sends `confirm`. + - An instance inside a global entry is refused (P11/`shapeViolation`). + - Generic WP tools (`content-update`, `post-trash`, `revision-restore`, `duplicate`) cannot reach `et_pb_layout` (not managed). +- **Dry run/confirm race for rights:** P8a is recomputed at confirm from a fresh query. The `tree_rev` lock covers entry content changes. +- **Content injection:** + - `localAttrs` and the snapshot children of instances are walked, and so are local children, because the parser keeps inner blocks of opaque blocks. + - `deglobalize` runs before the guard. + - CRLF unification touches whitespace-only text between blocks only. + - Invalid or non-`divi/` `blockName` instances are refused as opaque. + - Library titles go through `sanitize_text_field` (plus core title kses). + - Terms are limited to existing names. +- **Divi frontend sinks beyond interactions and Lottie** (scan of all `script-library-*.js`): + - module link → `diviElementLinkData`, where `LinkUtils::generate_data` applies `esc_url` before `window.location`/`window.open`; + - multi-view `innerHTML`/`setAttribute`: data via `wp_localize_script` (nested values are not entity-decoded), with fixed attribute names and per-attribute sanitizers; + - `new Function` hits are bundler global shims; + - magnific-popup, slider and menu read `src`/`href` from escaped markup. + - Together with the T11 live URL matrix, no new sink was found. +- **Disclosure:** + - `dynamic-sources` samples only an editable post, uses the `edit` context (custom meta hidden without `read_dynamic_content_custom_fields`) and returns no option defaults. + - Render-summary notes list only IDs already present in content the caller may read. + - site-status `problems` are labels of missing Divi internals only. + - Error texts are uniform for missing and unreadable IDs (P12; `{{link:}}` from T2). The known P13 leftovers are left to the final wave. +- **Multisite:** everything is per-blog (`$wpdb->posts`, Divi role options, `current_user_can`). No network-level path exists. Without `unfiltered_html` (site admins), the guard and the P9 backup refusal only get stricter. +- **DoS (other):** + - `lit/library-list` scan is capped (`MAX_SCAN` 500, `limit` ≤ `Cursor::MAX_LIMIT`). + - Guide `topic=all` is capped at 100 KB of static files. + - `affected` is capped at 50 rows (the cost is in L1, not the output). diff --git a/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md new file mode 100644 index 0000000..63e2b2e --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md @@ -0,0 +1,7316 @@ +# Etappe 3a – Divi-Bibliothek: Auflisten, Speichern, Einfügen, Kopieren, globale Module bearbeiten – 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 arbeitet mit der Divi-Bibliothek: Einträge auflisten und lesen (`lit/library-list`), Knoten als normalen oder globalen Eintrag speichern (`lit/library-save`), Einträge als Kopie oder als globale Instanz im Format des Visual Builders einfügen (`lit/library-insert`, direkt), Knoten zwischen Seiten kopieren (`lit/divi-node-copy`), Bibliothekseinträge – auch globale, mit Probelauf, `confirm` und Sicherung – mit den vorhandenen Divi-Werkzeugen bearbeiten, verfügbare Dynamic-Content-Quellen abfragen (`lit/divi-dynamic-sources`); dazu die Übernahmen aus Etappe 2 (`{{link:ID}}`, `lit-guide` mit allen Themen, S19) und der Hinweis auf globale Module in `lit/page-render-summary`. + +**Architecture:** Reines PHP in `Divi/BlockTree/` bekommt die Bausteine für globale Module (`Block::copyAs()`, `GlobalInstance` – Instanz im Builder-Format und Entglobalisieren wie Divis `maybe_deglobalize_nested_global_modules` –, `LibraryContent` – Rahmen `divi/placeholder`, Wurzeln, Formregeln). Divi-Interna liegen hinter Adaptern mit Live-Kompatibilitätsprüfung (Entscheidung E15): `DiviRoles` (`et_pb_is_allowed`), `DiviLibraryStore` (Post-Typ `et_pb_layout`, Terme, Meta, `GlobalLayout::is_global_layout_template`), `DiviCompatibility` (Befund in `lit/site-status`). `LibraryAccess` bündelt die Rechte je Objekt (E3) und wird in `DiviPageLoader` eingehängt, damit alle Divi-Werkzeuge Bibliotheks-IDs annehmen (E1); `GlobalEdits` ergänzt `DiviWriter::commit()` um Formregeln, Probelauf mit betroffenen Seiten, `confirm` und Sicherung (E2, Antwort F2). Jeder neue Weg nach `post_content` läuft weiter über `Composer`/`TreeEditor`/`DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard` gegen die S7/S26-Sperren (E6). Die neuen Tools bilden die Gruppe „Divi-Bibliothek“ (`LibraryIntegration`), `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, kses, Taxonomien), Divi 5.2.1 (`et_pb_layout`, `et_pb_is_allowed`, `GlobalLayout`, `DynamicContentOptions`, `ET_Core_PageResource`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §5.1–5.7, §8.1–8.2, §9, §10, §12 Etappe 3) und die Entscheidungen `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F1–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5; **die Antworten des Benutzers in „5. Antworten des Benutzers“ sind verbindlich**), dazu die Rulings `2026-09-26-etappe-{1a,1b,2a,2b}-rulings.md`, `2026-09-26-etappe-2-entscheidungen.md` (D1–D12, R2), Security-Audit und Praxistest 2b sowie die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Commit e7cc4ef). + +**Voraussetzung:** Branch `etappe-3` mit Stand 30788f2 (Etappe 2b vollständig, 1597 Tests grün). Pods ist lokal deaktiviert; `lit/divi-dynamic-sources` wird für Pods über Unit-Tests abgesichert. + +## Global Constraints + +- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.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. +- `src/Integrations/Divi/BlockTree/*` und `src/Integrations/Divi/Compose/*` bleiben reines PHP ohne WordPress-Aufrufe (Spec §4.4). WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/Design/`, `Divi/Schema/` und in `DiviCompatibility`. +- **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder neue Schreibweg in `post_content` – Seiten wie Bibliothekseinträge – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durch `StoredNodeGuard` (S7/S26-Pfade, Code-Modul, `$variable`-Einstellungen) – für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (`divi/v1/…`), kein `et_pb_submit_layout`/`et_pb_create_layout`. +- **Rechte je Objekt (Spec §4.5, E3 – strenger als Divi, nie lockerer):** Bibliothek lesen/auflisten = `edit_posts` + Divi-Rolle `divi_library`; einen Eintrag lesen = zusätzlich `edit_post` auf den Eintrag; speichern/anlegen = + `publish_posts` des Typs `et_pb_layout` + `save_library`; bearbeiten = `edit_post` + `save_library`, bei globalen Einträgen + `edit_global_library`; einfügen = + `add_library`; Dynamic-Content-Feldliste nur mit `read_dynamic_content_custom_fields`. Divi-Rollen nur über `DiviRoles`. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeuge `not_found` wie bisher (`ContentSchema::EXCLUDED` bleibt unverändert). +- **Sperren und Alles-oder-nichts:** Jede Divi-Schreibung verlangt `tree_rev` (Ruling P16); Werkzeuge mit Quellknoten (`lit/library-save`, `lit/divi-node-copy`) verlangen zusätzlich die `tree_rev` der Quelle. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann einmal speichern; beim Anlegen eines Bibliothekseintrags wird der Eintrag bei jedem Fehler endgültig gelöscht (wie `lit/content-create`). Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7). +- **Globale Einträge (Antwort F2, verbindlich):** Jede Änderung an einem globalen Bibliothekseintrag ist zuerst ein Probelauf (`confirm_required` mit `affected`/`affected_count`, nichts gespeichert); mit `confirm: true` wird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (`PostCloner::backup`, `backup_id`) –, danach seitenweit `remove_static_resources('all', 'all', true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5). +- **Exposition:** Direkt ist nur `lit/library-insert` (22 direkte Tools, Spec §4.3). Indirekt: `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Gruppe „Divi-Bibliothek“ (`divi-library`) für die drei `lit/library-*`-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (`ExposurePolicy::groupWritable('divi-library')`). `lit/library-insert` folgt zusätzlich `lit/divi-insert` (Audit M2). Jeder Task mit neuem direktem Tool aktualisiert `docs/guides/overview.md` „Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Tools `lit-`, indirekte `lit/` (ToolNamingTest). In PHP-Texten nie `topic ""` für ein Thema ohne `docs/guides/.md` schreiben (ToolNamingTest). +- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme und Abnahme auf Deutsch mit echten Umlauten (ä, ö, ü, ß). Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“ – keine Chatbot-Marken in UI, Guides, Readme oder Tool-Texten. +- Tests: Unit-Tests der 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; `error_log()` per `ini_set('error_log', …)` umleiten. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- `composer test` braucht mehr als 128 MB (Task 1 behebt das); danach laufen `composer test` und `php vendor/bin/phpunit` mit jeder `memory_limit`-Vorgabe. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden kurz „Commit wie in den Global Constraints“). + +## Review Focus + +1. **Redakteur ohne `unfiltered_html` überträgt gespeicherten Admin-Inhalt in einen anderen Post** (Abschnitt mit eigenem CSS, HTML-Attributen, Code-Modul oder Dynamic-Content-`before`/`after` mit HTML) über `lit/divi-node-copy`, `lit/library-insert` oder `lit/library-save`: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → Tests `StoredNodeGuardTest` (Task 11), `DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage` (Task 14), `LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss` (Task 13), `LibrarySaveAbilityTest::testEditorCannotSaveACodeModule` (Task 12). +2. **Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per `lit-divi-delete`, das ein eigenes `confirm` hat:** Ohne `confirm` nichts gespeichert, Antwort nennt die betroffenen Seiten; mit `confirm` Revision bzw. Sicherung und seitenweite Cache-Leerung → Tests `GlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing`, `::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm`, `::testWithoutRevisionsABackupIsMadeFirst` und `DiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage` (Task 9). +3. **Bibliothekseintrag verliert seine Form** (Wurzel gelöscht, zweite Wurzel, anderer Wurzeltyp, globales Modul in einem globalen Eintrag): Instanzen auf allen Seiten würden brechen → abgelehnt → Tests `LibraryContentTest::testShapeViolations` (Task 7) und `GlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot` (Task 9). +4. **Rechte:** Autor ohne Divi-Rolle `divi_library`/`save_library`/`add_library`/`edit_global_library`, fremder Eintrag ohne `edit_post`, Mitarbeiter ohne `publish_posts`, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhalten `forbidden`, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDs `not_found` → Tests `LibraryAccessTest` (Task 8), `DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess` (Task 8), `LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole` (Task 12). +5. **Parallele Änderung:** Quelle oder Ziel wurde seit dem Lesen geändert (`source_tree_rev`/`tree_rev` veraltet), oder der globale Eintrag änderte sich zwischen Probelauf und `confirm` → `rev_mismatch`, nichts geschrieben → Tests `DiviNodeCopyAbilityTest::testStaleSourceOrTargetWritesNothing` (Task 14), `LibrarySaveAbilityTest::testStaleSourceTreeRevCreatesNothing` (Task 12), `GlobalEditsTest::testConfirmWithAStaleTreeRevWritesNothing` (Task 9). + +--- + +## File Structure + +``` +phpunit.xml.dist (geändert) memory_limit 512M +composer.json (geändert) test-Skript mit memory_limit +wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.4.0; Verdrahtung Bibliothek + Gates +readme.txt (geändert) Bibliothek, globale Module, Changelog 0.4.0 +docs/guides/ + divi-library.md (neu) Thema „divi-library“ + overview.md, divi-placeholders.md, divi-structure.md, workflows.md, divi-design-system.md (geändert) +docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §5.2, §5.4, §5.7, §9.2 +docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 6 „Umsetzung 3a“ +docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md (neu) manuelle Abnahme +src/ + Abilities/ExposurePolicy.php (geändert) groupWritable() + Guides/InstructionsBuilder.php (geändert) Bibliotheks-Regel + Integrations/Core/EnvironmentProbe.php (geändert) Divi-Kompatibilitätsbefund + Integrations/Core/GuideAbility.php (geändert) topic = all + Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) Hinweis globale Module + Integrations/Divi/ + DiviIntegration.php (geändert) Bibliothekszugang, library(), neue Divi-Tools + DiviCompatibility.php (neu) Live-Prüfung der genutzten Divi-Interna + BlockTree/ + Block.php (geändert) copyAs() + Container.php (geändert) replaceChild(), unifyLineBreaks() + NodeSummary.php (geändert) globalRef() + Outline.php (geändert) global_id + GlobalInstance.php (neu) Instanz im Builder-Format, entglobalisieren, effectiveName() + LibraryContent.php (neu) Rahmen, Wurzeln, Formregeln + Compose/ + LinkTargets.php (neu) Schnittstelle für {{link:ID}} + Placeholders.php (geändert) {{link:ID}} + VariableRef.php (geändert) post_link_url_* nur in URL-Feldern, settingsOf() + StructureRules.php, TreeEditor.php (geändert) Wurzeltyp globaler Instanzen + StoredNodeGuard.php (neu) S7/S26 für übertragene gespeicherte Knoten + Design/DiviDesignSystemAbility.php (geändert) only/module-Filter (S19) + Pages/ + WpLinkTargets.php (neu) + SaveScope.php (neu) Seite / Bibliothekseintrag / globaler Eintrag + DiviSaver.php (geändert) SaveScope: Flags, seitenweiter Cache + DiviPage.php, DiviPageLoader.php (geändert) Bibliothekseinträge + DiviWriter.php, DiviWriteAbility.php (geändert) commit(), notice(), precheck() + DiviSchema.php (geändert) confirm + DiviComposition.php (geändert) LinkTargets, storedGuard() + DiviNodeGetAbility.php, DiviOutlineAbility.php (geändert) global + DynamicContentMap.php (geändert) options() + DiviNodeCopyAbility.php (neu) lit/divi-node-copy + DiviDynamicSourcesAbility.php (neu) lit/divi-dynamic-sources + Library/ + DiviRoles.php (neu) Adapter et_pb_is_allowed + DiviLibraryStore.php (neu) Adapter et_pb_layout, Terme, Meta + LibraryAccess.php (neu) Rechte je Objekt, Gruppen-Gate + LibraryItemSpec.php (neu) Terme/Meta eines neuen Eintrags + GlobalUsages.php (neu) Seiten mit Instanzen + GlobalEdits.php (neu) Formregeln, Probelauf, confirm, Sicherung + LibraryIntegration.php (neu) Gruppe „Divi-Bibliothek“ + LibraryListAbility.php (neu) lit/library-list + LibrarySaveAbility.php (neu) lit/library-save + LibraryInsertAbility.php (neu) lit/library-insert +tests/ + Stubs/EtCorePageResource.php (neu) zeichnet remove_static_resources auf + bootstrap.php (geändert) + Support/ComposeFixture.php, DiviWriterFixture.php (geändert) links(), Bibliothek + Support/LibraryFixtures.php (neu) Posts nach ID, Store/Rollen/Zugang + Fixtures/divi/stage3/README.md (geändert) Befund 9: CRLF in Bibliothekseinträgen + Unit/… (neu/geändert je Task) + Integration/divi-library-smoke.sh (neu) Ende-zu-Ende 3a +``` + +--- + +### Task 1: Testlauf ohne Speicherfehler + +`composer test` bricht mit dem PHP-Standard `memory_limit=128M` ab („Allowed memory size of 134217728 bytes exhausted“); der volle Lauf braucht ≈ 177 MB (gemessen: `php -d memory_limit=1G vendor/bin/phpunit` → „Memory: 177.00 MB“). Das ist kein Leck einzelner Tests, sondern die Summe aus 1597 Tests mit Mockery/Brain Monkey und dem 237-KB-Katalog, der je Test dekodiert wird. PHPUnit setzt Werte aus `` zur Laufzeit; das deckt `composer test` und den direkten Aufruf ab. + +**Files:** +- Modify: `phpunit.xml.dist` +- Modify: `composer.json` (`scripts.test`) + +**Interfaces:** +- Consumes: nichts. +- Produces: `composer test` und `php vendor/bin/phpunit` laufen unabhängig von der `memory_limit` der CLI. + +- [ ] **Step 1: Fehler nachweisen** + +Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | grep -m1 -o 'Allowed memory size of [0-9]* bytes exhausted'` +Expected: `Allowed memory size of 134217728 bytes exhausted` + +- [ ] **Step 2: Grenze in der PHPUnit-Konfiguration setzen** + +`phpunit.xml.dist` vollständig: + +```xml + + + + + + + + + tests/Unit + + + +``` + +- [ ] **Step 3: Composer-Skript absichern (greift schon vor dem Laden der Konfiguration)** + +In `composer.json` den Block `scripts` ersetzen durch: + +```json + "scripts": { + "test": "@php -d memory_limit=512M vendor/bin/phpunit", + "lint": "@php vendor/bin/phpcs" + }, +``` + +- [ ] **Step 4: Prüfen** + +Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | tail -1` +Expected: `OK (1597 tests, 7869 assertions)` + +Run: `composer test 2>&1 | tail -1` +Expected: `OK (1597 tests, 7869 assertions)` + +- [ ] **Step 5: Commit** + +```bash +git add phpunit.xml.dist composer.json +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: raise memory_limit for the unit suite (512M)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: Interner Link `{{link:ID}}` (E13a) + +Divis natives Dynamic Content `post_link_url_` mit `settings.post_id` rendert zur Laufzeit `esc_url(get_permalink($id))` (F27, `DynamicContentOptionCustomPostLinkUrl.php:186-217`) – entwurfssicher und nach dem Veröffentlichen automatisch die schöne URL. Der Name wird nicht fest verdrahtet: `VariableRef` prüft ihn gegen Divis Registry (nur öffentliche Post-Typen, `et_builder_get_public_post_types`), `select_post` prüft `read_post` (S37). Neu: `post_link_url_*` nur in URL-Feldern. + +**Files:** +- Create: `src/Integrations/Divi/Compose/LinkTargets.php` +- Create: `src/Integrations/Divi/Pages/WpLinkTargets.php` +- Modify: `src/Integrations/Divi/Compose/Placeholders.php` +- Modify: `src/Integrations/Divi/Compose/VariableRef.php` (`contentSettings()`) +- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` +- Modify: `tests/Support/ComposeFixture.php` (`links()`, `placeholders()`) +- Test: `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`, `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`, `tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php` + +**Interfaces:** +- Consumes: `VariableRef::serialize(string $type, string $name, array $settings): string`, `DynamicContentMap::fromOptions(array $options): array`, `DiviFixtures::dynamicContentRegistry()` (enthält `post_link_url_page` mit `post_id` = `select_post`). +- Produces: `interface LinkTargets { public function postTypeOf(int $id): ?string; }`; `new Placeholders(DesignSystem $design, ?LinkTargets $links = null)`; `WpLinkTargets implements LinkTargets`; `DiviComposition::__construct(…, LinkTargets $links = new WpLinkTargets())` (6. Parameter); `ComposeFixture::links(): LinkTargets` (839 → page, 840 → post, sonst null). + +- [ ] **Step 1: Failing tests schreiben** + +In `tests/Support/ComposeFixture.php` ergänzen (Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`) und `placeholders()` ersetzen: + +```php + /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */ + public static function links(): LinkTargets + { + return new class implements LinkTargets { + public function postTypeOf(int $id): ?string + { + return match ($id) { + 839 => 'page', + 840 => 'post', + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DiviFixtures::designSystem(), self::links()); + } +``` + +In `PlaceholdersTest::placeholders()` einen Eintrag ergänzen: + +```php + 'internal link' => [ + '{{link:839}}', + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + ], +``` + +Da `testResolvesToDiviVariables` das Ergebnis mit `ComposeFixture::references()` (Standard-Map ohne `post_link_url_page`) prüft, dort die Prüfung auf den Registry-Checker umstellen: + +```php + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $placeholders = ComposeFixture::placeholders(); + $resolved = $placeholders->resolve($input); + + self::assertSame($expected, $resolved); + if ($expected !== $input) { + // What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live + // registry map knows post_link_url_page, the URL mode allows it. + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), true)); + } + } +``` + +(Imports: `use LIT\MCP\Integrations\Divi\Compose\VariableRef;`, `use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;`, `use LIT\MCP\Tests\Support\DiviFixtures;`.) Hinweis: Im URL-Modus sind `before`/`after` nur leer erlaubt – alle bisherigen Zeilen liefern leere `before`/`after`, sie bleiben gültig. + +In `PlaceholdersTest::invalid()` ergänzen: + +```php + 'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'], + 'link to a missing post' => ['{{link:999}}', 'Eintrag 999'], + 'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'], +``` + +und am Ende von `PlaceholdersTest` einen Test ohne `LinkTargets`: + +```php + public function testLinksNeedLinkTargets(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Eintrag 839'); + + (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}'); + } +``` + +In `ShortformWriterTest` (Imports `ShortformWriter`, `Placeholders`, `ValueCleaner`, `VariableRef`, `DynamicContentMap`, `DiviFixtures`): + +```php + /** E13a: {{link:ID}} becomes Divi's post_link_url_ in URL fields – and only there. */ + public function testInternalLinkOnlyInUrlFields(): void + { + $design = DiviFixtures::designSystem(); + $cleaner = new ValueCleaner( + DiviFixtures::catalog(), + new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())), + ComposeFixture::sanitizer() + ); + $writer = new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); + + $button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n'); + self::assertSame([], $button['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + $button['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + + $text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']); + + // A post type Divi offers no link option for (not public): unknown name. + $post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n'); + self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']); + } +``` + +`tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php`: + +```php + new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']), + 841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']), + 842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']), + 843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']), + ]; + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842 + ); + + $links = new WpLinkTargets(); + + self::assertSame('page', $links->postTypeOf(839)); + self::assertNull($links->postTypeOf(841), 'trashed'); + self::assertNull($links->postTypeOf(842), 'not readable'); + self::assertNull($links->postTypeOf(843), 'revision'); + self::assertNull($links->postTypeOf(900), 'missing'); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest'` +Expected: FAIL (`Class "LIT\MCP\Integrations\Divi\Compose\LinkTargets" not found`) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Compose/LinkTargets.php`: + +```php +}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress. + */ +interface LinkTargets +{ + /** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */ + public function postTypeOf(int $id): ?string; +} +``` + +`src/Integrations/Divi/Pages/WpLinkTargets.php`: + +```php +}} targets from WordPress: the entry exists, lives (no trash, auto-draft or + * revision) and the current user may read it (read_post, like select_post settings, S37). + */ +final class WpLinkTargets implements LinkTargets +{ + private const DEAD = ['trash', 'auto-draft', 'inherit']; + + public function postTypeOf(int $id): ?string + { + $post = get_post($id); + if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) { + return null; + } + + return current_user_can('read_post', $id) ? (string) $post->post_type : null; + } +} +``` + +`Placeholders.php`: Konstruktor, Muster, Fehlermeldung und neue Methode. + +```php + private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/'; + + public function __construct(private DesignSystem $design, private ?LinkTargets $links = null) + { + } +``` + +In `resolve()` die Meldung zu „(bekannt: color, var, font, post, field, link)“ ändern und das `match` erweitern: + +```php + return match ($kind) { + 'color' => $this->color($name), + 'var' => $this->variable($name), + 'font' => $this->font($name), + 'post' => $this->post($name), + 'link' => $this->link($name), + default => $this->field($name), + }; +``` + +Neue Methode (Klassen-Docblock um `{{link:}}` ergänzen): + +```php + /** + * {{link:}} → Divi's own internal link post_link_url_ with settings.post_id (F27): + * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published. + * Whether Divi offers that option (public post types only) VariableRef checks against the registry. + */ + private function link(string $name): string + { + if (preg_match('/^[1-9][0-9]{0,19}$/D', $name) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).', + $name + )); + } + + $type = $this->links?->postTypeOf((int) $name); + if ($type === null) { + throw new InvalidArgumentException(sprintf( + 'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.', + $name + )); + } + + return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]); + } +``` + +`VariableRef::contentSettings()` – vor der Schleife einfügen: + +```php + if (! $url && str_starts_with($name, 'post_link_url_')) { + throw new InvalidArgumentException(sprintf( + '"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.', + $name + )); + } +``` + +`DiviComposition`: Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`, Konstruktor um `private LinkTargets $links = new WpLinkTargets()` als letzten Parameter erweitern und `placeholders()` ändern: + +```php + return $this->placeholders ??= new Placeholders($this->design->read(), $this->links); +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest|VariableRefTest|DiviCompositionTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK / keine Fehler + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Compose/LinkTargets.php src/Integrations/Divi/Pages/WpLinkTargets.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content`. + +--- + +### Task 3: `lit-guide` mit allen Themen (E13b) + +Praxistest-Wunsch 7: alle Themen in einem Aufruf. `topic` = `all` liefert zuerst `overview`, dann die übrigen Themen alphabetisch; Obergrenze 100 000 Bytes, nicht aufgenommene Themen werden am Ende genannt. + +**Files:** +- Modify: `src/Integrations/Core/GuideAbility.php` +- Test: `tests/Unit/Integrations/Core/GuideAbilityTest.php` + +**Interfaces:** +- Consumes: `GuideRepository::topics(): list`, `GuideRepository::get(string $topic): ?string`. +- Produces: `lit/guide` mit `topic` = `all` → `{topic: "all", topics: list, content: string}`; Konstante `GuideAbility::ALL_LIMIT = 100000`. + +- [ ] **Step 1: Failing test schreiben** + +In `GuideAbilityTest` ergänzen (`$this->ability` liest `tests/Fixtures/guides` mit `overview.md` und `divi-shortform.md`): + +```php + public function testAllTopicsInOneCallOverviewFirst(): void + { + $result = $this->ability->execute(['topic' => 'all']); + + self::assertSame('all', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + $overview = strpos($result['content'], 'Thema `overview`'); + $shortform = strpos($result['content'], 'Thema `divi-shortform`'); + self::assertIsInt($overview); + self::assertIsInt($shortform); + self::assertLessThan($shortform, $overview); + self::assertStringNotContainsString('Nicht enthalten', $result['content']); + } + + public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void + { + $big = str_repeat('x', GuideAbility::ALL_LIMIT); + // Brain Monkey owns apply_filters(): answer the guide filter through its hooks API. + Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]); + $ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + + $content = $ability->execute(['topic' => 'all'])['content']; + + self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content)); + self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content); + } +``` + +(Import `use Brain\Monkey\Filters;`.) + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter GuideAbilityTest` +Expected: FAIL (`Undefined constant … ALL_LIMIT` bzw. `Unbekanntes Thema`) + +- [ ] **Step 3: Implementieren** + +In `GuideAbility`: + +```php + private const DEFAULT_TOPIC = 'overview'; + + /** topic = all: every topic in one call (overview first). */ + public const ALL = 'all'; + + /** Upper bound of the combined text of topic = all (bytes). */ + public const ALL_LIMIT = 100000; +``` + +`description()`: + +```php + return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; ' + . 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).'; +``` + +Beschreibung des Schema-Felds `topic`: `'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.'` + +In `execute()` direkt nach `$topics = $this->guides->topics();`: + +```php + if ($topic === self::ALL) { + return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)]; + } +``` + +Neue Methode: + +```php + /** + * Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names + * the topics left out. + * + * @param list $topics + */ + private function all(array $topics): string + { + $ordered = in_array(self::DEFAULT_TOPIC, $topics, true) + ? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC]))) + : $topics; + $parts = []; + $size = 0; + $left = []; + foreach ($ordered as $topic) { + $content = $this->guides->get($topic); + if ($content === null) { + continue; + } + + $part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content)); + if ($size + strlen($part) > self::ALL_LIMIT) { + $left[] = $topic; + continue; + } + + $parts[] = $part; + $size += strlen($part) + 2; + } + + if ($left !== []) { + $parts[] = sprintf( + 'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.', + implode(', ', $left) + ); + } + + return implode("\n\n", $parts); + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'GuideAbilityTest|ToolNamingTest'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Core/GuideAbility.php tests/Unit/Integrations/Core/GuideAbilityTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit-guide topic all returns every guide in one call`. + +--- + +### Task 4: S19 – Designsystem filtern, globale Module in `node-get`/`outline` + +Ruling S19 (aus 2a F2): `lit-divi-design-system` wird auf großen Websites mit hunderten Presets unhandlich → Filter `only` (colors/variables/presets) und `module` (nur dessen Presets), höchstens 200 Presets je Antwort. `lit-divi-node-get` und `lit-divi-outline` nennen bei globalen Modulen die Bibliotheks-ID und den Wurzeltyp (E2). + +**Files:** +- Modify: `src/Integrations/Divi/Design/DiviDesignSystemAbility.php` +- Modify: `src/Integrations/Divi/BlockTree/NodeSummary.php` (`globalRef()`) +- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (`global_id`) +- Modify: `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` (`global`) +- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php` (Schema, Hinweis) +- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `DesignSystem::presets(): list`, Fixture `tests/Fixtures/divi/stage3/page--globale-instanzen.html` (g0 = Instanz 1058 `divi/section`, s0.r0.c0.g0 = Instanz 1059 `divi/text`). +- Produces: `NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}`; Outline-Zeilen mit `global_id: ?string`; `lit/divi-node-get` mit `global: null|{id, block_name, hint}`; `lit/divi-design-system` mit `only`, `module`. + +- [ ] **Step 1: Failing tests schreiben** + +`OutlineTest::testRowsAndTextOfAPage`: im erwarteten Array `'truncated' => false]` durch `'truncated' => false, 'global_id' => null]` ersetzen. Neuer Test in `OutlineTest`: + +```php + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline(); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertSame('1058', $byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertFalse($byId['g0']['editable']); + self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']); + self::assertSame('text', $byId['s0.r0.c0.g0']['type']); + self::assertNull($byId['s0']['global_id']); + self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows)); + } +``` + +(Import `use LIT\MCP\Tests\Support\DiviFixtures;` – `DiviFixtures::markup()` ist reines PHP und liest `tests/Fixtures/divi/`.) + +In `DiviReadAbilitiesTest`: + +```php + public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + + $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); + + self::assertSame('1058', $global['global']['id']); + self::assertSame('divi/section', $global['global']['block_name']); + self::assertStringContainsString('post_id 1058', $global['global']['hint']); + self::assertFalse($global['editable']); + self::assertNull($plain['global']); + } +``` + +In `DiviDesignSystemAbilityTest` (Muster der vorhandenen Tests: Reader-Mock mit `DiviFixtures::designSystem()`): + +```php + public function testFiltersPartsAndPresetsOfOneModule(): void + { + Functions\when('current_user_can')->justReturn(true); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); + $ability = new DiviDesignSystemAbility($reader); + + $result = $ability->execute(['only' => ['presets'], 'module' => 'button']); + + self::assertSame([], $result['colors']); + self::assertSame([], $result['variables']); + self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id')); + self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module')))); + self::assertNotEmpty($result['fonts']['heading']); + self::assertContains('Nicht angefordert: colors, variables.', $result['notes']); + } + + public function testCapsThePresetList(): void + { + Functions\when('current_user_can')->justReturn(true); + $items = []; + for ($i = 0; $i < 250; ++$i) { + $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text']; + } + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn($design); + + $result = (new DiviDesignSystemAbility($reader))->execute([]); + + self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); + self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + } +``` + +(Imports: `DesignSystem`, `DesignSystemReader`, `DiviFixtures`, `Mockery`, `Functions`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest'` +Expected: FAIL (`global_id`, `global`, `PRESET_LIMIT` fehlen) + +- [ ] **Step 3: Implementieren** + +`NodeSummary`: + +```php + /** + * Library entry of a global module: a divi/global-layout instance (its root type in blockName) + * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block. + * + * @return array{id: string, block_name: string}|null + */ + public static function globalRef(Block $block): ?array + { + if (! $block->isGlobal()) { + return null; + } + + $attrs = $block->attrs(); + $id = $attrs['globalModule'] ?? null; + if (! is_string($id) && ! is_int($id)) { + return null; + } + + $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name(); + + return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : '']; + } +``` + +`Outline::row()` – Typ und `global_id` (Docblock-Rückgabetyp um `global_id: ?string` ergänzen): + +```php + private function row(Block $block, string $id, int $depth): array + { + $editable = $block->isDivi() && ! $block->isOpaque(); + $global = NodeSummary::globalRef($block); + $type = $block->isDivi() ? $block->shortName() : $block->name(); + if ($global !== null && $global['block_name'] !== '') { + $type = str_starts_with($global['block_name'], 'divi/') ? substr($global['block_name'], 5) : $global['block_name']; + } + + return [ + 'id' => $id, + 'type' => $type, + 'label' => NodeSummary::label($block), + 'excerpt' => NodeSummary::excerpt($block), + 'info' => $editable ? NodeSummary::info($block) : '', + 'presets' => array_map( + fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, + NodeSummary::presetIds($block) + ), + 'children' => $editable ? count($block->children()) : 0, + 'editable' => $editable, + 'depth' => $depth, + 'truncated' => false, + 'global_id' => $global['id'] ?? null, + ]; + } +``` + +`NodeSummary::label()` liest `module.meta.adminLabel` aus den Attributen; bei `divi/global-layout` steht das Label in `localAttrs`. In `NodeSummary::label()` die erste Zeile ersetzen durch: + +```php + $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) + ? $block->attrs()['localAttrs'] + : $block->attrs(); + $label = AttrMap::desktop($attrs, 'module.meta.adminLabel'); +``` + +`Outline::text()` – nach der Label-Zeile: + +```php + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; +``` + +`DiviOutlineAbility::outputSchema()` – in den Zeilen-`properties` `'global_id' => ['type' => ['string', 'null'], 'description' => 'Bibliotheks-ID eines globalen Moduls.']` ergänzen und `'global_id'` in `required` aufnehmen. In `hints()` den Text für nur lesbare Knoten ersetzen: + +```php + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' + . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; +``` + +`DiviNodeGetAbility`: im `outputSchema` `'global' => ['type' => ['object', 'null']]` ergänzen (nicht in `required`); in `execute()` vor dem `return`: + +```php + $global = NodeSummary::globalRef($block); + if ($global !== null) { + $global['hint'] = sprintf( + 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' + . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', + $global['id'], + $global['id'] + ); + } +``` + +und `'global' => $global,` ins Ergebnis-Array. + +`DiviDesignSystemAbility`: + +```php + /** Presets per answer (ruling S19); narrow with "module". */ + public const PRESET_LIMIT = 200; + + private const PARTS = ['colors', 'variables', 'presets']; +``` + +`description()` um „Filter: only (colors/variables/presets) und module (Presets eines Moduls).“ ergänzen. `inputSchema()`: + +```php + return [ + 'type' => 'object', + 'properties' => [ + 'only' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::PARTS], + 'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer dabei.', + ], + 'module' => [ + 'type' => 'string', + 'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".', + ], + ], + 'additionalProperties' => false, + ]; +``` + +`execute()` vollständig: + +```php + public function execute(array $input): mixed + { + $design = $this->reader->read(); + $only = is_array($input['only'] ?? null) && $input['only'] !== [] + ? array_values(array_intersect(self::PARTS, $input['only'])) + : self::PARTS; + $notes = [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ]; + $skipped = array_values(array_diff(self::PARTS, $only)); + if ($skipped !== []) { + $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.'; + } + + $presets = $design->presets(); + $module = is_string($input['module'] ?? null) ? trim($input['module']) : ''; + if ($module !== '') { + $name = str_contains($module, '/') ? $module : 'divi/' . $module; + $presets = array_values(array_filter( + $presets, + static fn (array $preset): bool => $preset['module'] === $name + )); + } + if (count($presets) > self::PRESET_LIMIT) { + $notes[] = sprintf( + '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', + self::PRESET_LIMIT, + count($presets) + ); + $presets = array_slice($presets, 0, self::PRESET_LIMIT); + } + + return [ + 'colors' => in_array('colors', $only, true) ? array_map( + static fn (array $color): array => $color + [ + 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), + ], + $design->colors() + ) : [], + 'variables' => in_array('variables', $only, true) ? array_map( + static fn (array $variable): array => $variable + + ['placeholder' => '{{var:' . $variable['label'] . '}}'], + $design->variables() + ) : [], + 'fonts' => $design->fonts(), + 'sizes' => $design->sizes(), + 'presets' => in_array('presets', $only, true) ? $presets : [], + 'notes' => $notes, + ]; + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest|DiviWriterTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Design/DiviDesignSystemAbility.php src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php src/Integrations/Divi/Pages/DiviOutlineAbility.php tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: design-system filters and global module ids in node-get/outline (S19)`. + +--- + +### Task 5: `lit-page-render-summary` meldet globale Module (E13d, F8) + +Divis `BlockParser` expandiert `divi/global-layout` nicht bei REST-POST-Anfragen (F8, `BlockParser.php:827-831`) – unsere MCP-Aufrufe sind REST-POST, der Auszug enthält globale Module also nicht. Statt einen Divi-internen Schalter zu umgehen, meldet der Auszug jede Instanz (Divis eigene Erkennung per Zeichenkettensuche, F10). Bibliotheks-IDs bleiben für das Tool `not_found` (`ContentSchema::EXCLUDED`). + +**Files:** +- Modify: `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php` +- Test: `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php` + +**Interfaces:** +- Consumes: `PageRenderer::render(WP_Post): string`, `RenderSummary::fromHtml()`. +- Produces: `notes` enthält je globaler Instanz `Globales Modul ist im Auszug nicht enthalten …` (höchstens 20). + +- [ ] **Step 1: Failing test schreiben** + +Im vorhandenen Test das Muster zum Stubben von `get_post`, `current_user_can`, `get_permalink` und des `PageRenderer`-Mocks übernehmen (siehe bestehende Tests der Datei): + +```php + public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer); + + $notes = $ability->execute(['post_id' => 7])['notes']; + + $globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul'))); + self::assertCount(2, $globals); + self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]); + self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]); + } + + public function testLibraryEntriesAreNoManagedContent(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout'])); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + $ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class)); + + self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found'); + } +``` + +(Imports: `ContentSchema`, `SchemaFixture`, `DiviFixtures`, `PageRenderer`, `Mockery`, `WP_Post`, `Functions`.) + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter PageRenderSummaryAbilityTest` +Expected: FAIL (`assertCount(2, …)`: 0) + +- [ ] **Step 3: Implementieren** + +In `PageRenderSummaryAbility`: + +```php + /** At most this many global-module notes per summary. */ + private const GLOBAL_NOTES = 20; +``` + +In `execute()` nach dem Passwort-Hinweis: + +```php + foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) { + $notes[] = sprintf( + 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' + . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', + $id, + $id + ); + } +``` + +Neue Methode: + +```php + /** + * IDs of the global modules in $content, found the way Divi finds them (a string scan for + * "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there). + * + * @return list + */ + private static function globalModuleIds(string $content): array + { + preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches); + + return array_values(array_unique($matches[1])); + } +``` + +`description()` um den Satz „Globale Module (Divi-Bibliothek) fehlen im Auszug und werden unter notes genannt.“ ergänzen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|ToolNamingTest'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress/Render/PageRenderSummaryAbility.php tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: render summary notes global modules it cannot show (F8)`. + +--- + +### Task 6: Divi-Adapter mit Kompatibilitätsprüfung – `DiviRoles`, `DiviLibraryStore` (lesen), `DiviCompatibility` + +Schnittstellen-Einordnung (Entscheidungen §5, E15): Divis Rollen-Editor (`et_pb_is_allowed`, Stufe c), der Bibliotheks-Post-Typ mit Termen/Meta (c/d) und `GlobalLayout::is_global_layout_template` (c) werden nur über Adapter angesprochen. Fehlt eine Funktion/Klasse nach einem Divi-Update, liefern die Adapter „nicht erlaubt“ bzw. den Rückfall, nie einen Fatal; `lit-site-status` zeigt den Befund. Die Builder-Routen `divi/v1/divi-library/*` werden nicht benutzt (Nonce, keine Rechte je Eintrag, F9/F11). + +**Files:** +- Create: `src/Integrations/Divi/Library/DiviRoles.php` +- Create: `src/Integrations/Divi/Library/DiviLibraryStore.php` +- Create: `src/Integrations/Divi/DiviCompatibility.php` +- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`REGISTRY` öffentlich) +- Modify: `src/Integrations/Core/EnvironmentProbe.php` +- Test: `tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`, `tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`, `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`, `tests/Unit/Integrations/Core/EnvironmentProbeTest.php` + +**Interfaces:** +- Consumes: `Errors::forbidden()`, `DiviModules::isAvailable()`, `WP_Term`-Stub. +- Produces: + - `class DiviRoles { __construct(?Closure $check = null); allows(string $capability): bool; refusal(string $capability): ?WP_Error }` + - `class DiviLibraryStore { const POST_TYPE = 'et_pb_layout'; const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout'; const LAYOUT_TYPES = ['layout','section','row','module']; const TAXONOMIES = ['scope','layout_type','module_width']; isGlobal(int $id): bool; layoutType(int $id): ?string; termSlugs(int $id, string $taxonomy): list; termNames(int $id, string $taxonomy): list; find(array $filters, int $limit, int $offset): list; describe(WP_Post $post): array; missingTaxonomies(): list }` (nicht final, mockbar; `create()`/`delete()` folgen in Task 12) + - `final class DiviCompatibility { __construct(?array $checks = null); problems(): list; static defaults(): array }` + - `EnvironmentProbe::__construct(?DiviCompatibility $divi = null)`; jede Komponente hat zusätzlich `problems: list`. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`: + +```php + $cap !== 'edit_global_library'); + + self::assertTrue($roles->allows('divi_library')); + self::assertNull($roles->refusal('divi_library')); + $refusal = $roles->refusal('edit_global_library'); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message()); + self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message()); + } + + public function testWithoutDiviNothingIsAllowed(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new DiviRoles())->allows('divi_library')); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`: + +```php + [ + 'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + 'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])], + ], + 1055 => [ + 'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + ], + 1057 => [], + ]; + Functions\when('get_the_terms')->alias( + static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : '' + ); + } + + public function testGlobalScopeAndLayoutTypeFromTheTerms(): void + { + $store = new DiviLibraryStore(); + + self::assertTrue($store->isGlobal(1058)); + self::assertFalse($store->isGlobal(1055)); + self::assertSame('section', $store->layoutType(1058)); + self::assertNull($store->layoutType(1057)); + self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category')); + } + + public function testDescribesAnEntry(): void + { + $post = new WP_Post([ + 'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul', + 'post_modified_gmt' => '2026-09-26 20:56:29', + ]); + + self::assertSame( + [ + 'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal', + 'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29', + ], + (new DiviLibraryStore())->describe($post) + ); + } + + public function testFindQueriesPublishedEntriesWithFilters(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find( + ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'], + 20, + 40 + ); + + self::assertSame('et_pb_layout', $captured['post_type']); + self::assertSame('publish', $captured['post_status']); + self::assertSame(21, $captured['posts_per_page']); + self::assertSame(40, $captured['offset']); + self::assertSame('Kopf', $captured['s']); + self::assertSame( + [ + 'relation' => 'AND', + ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']], + ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'], + ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']], + ], + $captured['tax_query'] + ); + } + + public function testReportsMissingTaxonomies(): void + { + Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); + + self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); + } +} +``` + +`tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`: + +```php + static fn (): bool => true, + 'B' => static fn (): bool => false, + 'C' => static function (): bool { + throw new RuntimeException('Divi changed'); + }, + ]); + + self::assertSame(['B', 'C'], $compatibility->problems()); + } + + public function testDefaultsCoverTheDiviInternalsInUse(): void + { + self::assertSame( + [ + 'ModuleRegistration (Modul-Katalog)', + 'DynamicContentOptions (Dynamic Content)', + 'et_pb_is_allowed (Divi-Rollen)', + 'Post-Typ et_pb_layout (Bibliothek)', + 'Taxonomien scope, layout_type, module_width (Bibliothek)', + 'GlobalLayout::is_global_layout_template (globale Module)', + 'ET_Core_PageResource (CSS-Cache)', + ], + array_keys(DiviCompatibility::defaults()) + ); + } +} +``` + +`tests/Unit/Integrations/Core/EnvironmentProbeTest.php`: + +```php +allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])); + $components = array_column($probe->components(), null, 'id'); + + self::assertSame(['GlobalLayout'], $components['divi']['problems']); + self::assertSame([], $components['pods']['problems']); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest'` +Expected: FAIL (Klassen fehlen) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/DiviRoles.php`: + +```php + 'Divi-Bibliothek', + 'save_library' => 'In der Bibliothek speichern', + 'add_library' => 'Aus der Bibliothek hinzufügen', + 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', + 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + ]; + + /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ + public function __construct(private ?Closure $check = null) + { + } + + public function allows(string $capability): bool + { + if ($this->check !== null) { + return ($this->check)($capability); + } + + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability); + } + + /** Null when the role allows $capability, else a forbidden error naming the option. */ + public function refusal(string $capability): ?WP_Error + { + if ($this->allows($capability)) { + return null; + } + + return Errors::forbidden( + sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } +} +``` + +`src/Integrations/Divi/Library/DiviLibraryStore.php`: + +```php +termSlugs($id, 'scope'), true); + } + + /** "layout", "section", "row" or "module"; null when the entry has no known type. */ + public function layoutType(int $id): ?string + { + foreach ($this->termSlugs($id, 'layout_type') as $slug) { + if (in_array($slug, self::LAYOUT_TYPES, true)) { + return $slug; + } + } + + return null; + } + + /** @return list */ + public function termSlugs(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy)); + } + + /** @return list */ + public function termNames(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy)); + } + + /** + * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). + * + * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters + * scope: "global" or "normal"; category: name of a layout_category term. + * @return list + */ + public function find(array $filters, int $limit, int $offset): array + { + $taxQuery = []; + if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) { + $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]]; + } + if (($filters['scope'] ?? '') === 'global') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']]; + } elseif (($filters['scope'] ?? '') === 'normal') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN']; + } + if (($filters['category'] ?? '') !== '') { + $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]]; + } + + $args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + if (($filters['search'] ?? '') !== '') { + $args['s'] = $filters['search']; + } + if ($taxQuery !== []) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. + $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery); + } + + return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post)); + } + + /** + * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string, + * categories: list, tags: list, modified: string} + */ + public function describe(WP_Post $post): array + { + $id = (int) $post->ID; + $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true); + + return [ + 'id' => $id, + 'title' => (string) $post->post_title, + 'layout_type' => $this->layoutType($id), + 'scope' => $this->isGlobal($id) ? 'global' : 'normal', + 'module_type' => $moduleType === '' ? null : $moduleType, + 'categories' => $this->termNames($id, 'layout_category'), + 'tags' => $this->termNames($id, 'layout_tag'), + 'modified' => (string) $post->post_modified_gmt, + ]; + } + + /** + * Library taxonomies not registered in this request (F2: Divi registers them in + * ET_Builder_Library; fixture finding 6: not in every context). + * + * @return list + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** @return list */ + private function terms(int $id, string $taxonomy): array + { + $terms = get_the_terms($id, $taxonomy); + + return is_array($terms) + ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term)) + : []; + } +} +``` + +`DynamicContentMap`: `private const REGISTRY` → `public const REGISTRY` (Docblock: „also checked by DiviCompatibility“). + +`src/Integrations/Divi/DiviCompatibility.php`: + +```php +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list Labels of the checks that failed or threw. */ + public function problems(): array + { + $problems = []; + foreach ($this->checks ?? self::defaults() as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $problems[] = (string) $label; + } + } + + return $problems; + } + + /** @return array */ + public static function defaults(): array + { + return [ + 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), + 'DynamicContentOptions (Dynamic Content)' => static fn (): bool => class_exists(DynamicContentMap::REGISTRY), + 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), + 'Post-Typ et_pb_layout (Bibliothek)' => static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), + 'Taxonomien scope, layout_type, module_width (Bibliothek)' => + static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], + 'GlobalLayout::is_global_layout_template (globale Module)' => + static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), + 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), + ]; + } +} +``` + +`EnvironmentProbe::components()` vollständig ersetzen (Konstruktor ergänzen, Import `use LIT\MCP\Integrations\Divi\DiviCompatibility;`): + +```php + public function __construct(private ?DiviCompatibility $divi = null) + { + } + + /** @return list}> */ + public function components(): array + { + $theme = wp_get_theme(); + $isDivi = $theme->get_template() === 'Divi'; + $parent = $theme->parent(); + $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null; + + $tecActive = class_exists('Tribe__Events__Main'); + + return [ + [ + 'id' => 'divi', + 'active' => $isDivi, + 'version' => $diviVersion, + 'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [], + ], + [ + 'id' => 'pods', + 'active' => defined('PODS_VERSION'), + 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + 'problems' => [], + ], + [ + 'id' => 'the-events-calendar', + 'active' => $tecActive, + 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, + 'problems' => [], + ], + ]; + } +``` + +In `SiteStatusAbility::description()` den Satz „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ ergänzen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest|SiteStatusAbilityTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library/DiviRoles.php src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/DiviCompatibility.php src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Core/EnvironmentProbe.php src/Integrations/Core/SiteStatusAbility.php tests/Unit/Integrations/Divi/Library tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Core/EnvironmentProbeTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: Divi adapters for roles and library with a live compatibility check`. + +--- + +### Task 7: Bausteine globaler Module im BlockTree – `copyAs`, `GlobalInstance`, `LibraryContent` + +Rein PHP, gegen die echten Builder-Fixtures. Befunde aus `tests/Fixtures/divi/stage3/`: (a) eine globale Instanz ist `divi/global-layout` mit `globalModule`, `blockName`, `localAttrs` (vollständiger Schnappschuss der Wurzel-Attribute, oberste Schlüssel sortiert – im Fixture `builderVersion, content, module` gegenüber `module, content, builderVersion` im Bibliothekseintrag), die Kinder mit ihren echten Blocknamen und `"globalParent":""` am Ende; eine Instanz ohne Kinder ist selbstschließend. (b) **Neu:** Bibliothekseinträge, die über Divis Dialog „Zur Bibliothek hinzufügen“ gespeichert wurden, haben zwischen den Blöcken `\r\n` (Formular-Übertragung des Browsers), Seiten `\n`; der Builder schreibt Instanzen mit `\n`. Deshalb normalisiert `LibraryContent::load()` Zeilenumbrüche im reinen Leerraum zwischen Blöcken. Mit beidem entsteht die Instanz byte-genau wie im Fixture (vorab per Skript geprüft). + +**Files:** +- Modify: `src/Integrations/Divi/BlockTree/Block.php` (`copyAs()`) +- Modify: `src/Integrations/Divi/BlockTree/Container.php` (`replaceChild()`, `unifyLineBreaks()`) +- Create: `src/Integrations/Divi/BlockTree/GlobalInstance.php` +- Create: `src/Integrations/Divi/BlockTree/LibraryContent.php` +- Modify: `src/Integrations/Divi/Compose/TreeEditor.php` (Wurzeltyp globaler Instanzen) +- Modify: `tests/Fixtures/divi/stage3/README.md` (Befund 9) +- Test: `tests/Unit/Integrations/Divi/BlockTree/BlockTest.php`, `…/ContainerTest.php`, `…/GlobalInstanceTest.php`, `…/LibraryContentTest.php`, `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php` + +**Interfaces:** +- Consumes: `Parser::parse()`, `Serializer::block()`, `NodeIndex::build()`, `DiviFixtures::markup()`. +- Produces: + - `Block::copyAs(string $name, array $attrs): Block` + - `Container::replaceChild(Block $old, Block $new): void`, `Container::unifyLineBreaks(): void` + - `GlobalInstance::BLOCK = 'divi/global-layout'`, `GlobalInstance::effectiveName(Block): string`, `GlobalInstance::of(Block $root, int $libraryId): Block`, `GlobalInstance::deglobalize(Block $block): Block` + - `LibraryContent::load(Parser $parser, string $content): Document` (wirft `MalformedMarkup`), `LibraryContent::wrap(Block $node): string`, `LibraryContent::roots(Document $document): list`, `LibraryContent::containsGlobal(Block $block): bool`, `LibraryContent::shapeViolation(list $before, list $after, ?string $layoutType, bool $global): ?string` + +- [ ] **Step 1: Failing tests schreiben** + +In `BlockTest`: + +```php + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame("\n\n", Serializer::block($row)); + self::assertFalse($row->copyAs('divi/row', [])->isOpaque()); + } + + public function testCopyAsRefusesInvalidNames(): void + { + $this->expectException(InvalidArgumentException::class); + + Block::create('divi/text')->copyAs('Divi Text', []); + } +``` + +In `ContainerTest`: + +```php + public function testReplaceChildKeepsTheTextAround(): void + { + $document = (new Parser())->parse("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } +``` + +(Imports `Block`, `Parser`, `Serializer`, `InvalidArgumentException` je nach Datei ergänzen.) + +`tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php`: + +```php +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + 'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'], + ]; + } + + /** @dataProvider instances */ + public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, '')) + ) + ); + } + + public function testContainsGlobal(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertTrue(LibraryContent::containsGlobal($index->find('s0'))); + self::assertFalse(LibraryContent::containsGlobal($index->find('s1'))); + } + + public function testShapeViolations(): void + { + $section = Block::create('divi/section'); + $row = Block::create('divi/row'); + $instance = GlobalInstance::of(Block::create('divi/text'), 9); + $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [ + Block::create('divi/column', [], [$instance]), + ])]); + + self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false)); + self::assertStringContainsString('nicht leer', (string) LibraryContent::shapeViolation([$section], [], 'section', false)); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$row], null, false) + ); + self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false)); + self::assertStringContainsString( + 'nur Abschnitte', + (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false) + ); + self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false)); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true) + ); + } +} +``` + +In `TreeEditorTest`: + +```php + /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */ + public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void + { + $document = $this->document('stage3/page--globale-instanzen.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1']))); + + $index = NodeIndex::build($document); + $module = GlobalInstance::of(Block::create('divi/text'), 1059); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module])); + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)]) + ); + } +``` + +(Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest'` +Expected: FAIL (Methoden/Klassen fehlen) + +- [ ] **Step 3: Implementieren** + +`Block.php` – neue Methode nach `copy()`: + +```php + /** + * A deep copy of this block under another name and attributes: opener and closer are + * regenerated, children and the text between them are kept, the void flag too. Opaque exactly + * when the Parser would make it so. GlobalInstance uses it to turn a library root into an + * instance and back. + * + * @param array $attrs + */ + public function copyAs(string $name, array $attrs): self + { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name)); + } + + $copy = clone $this; + $copy->name = $name; + $copy->attrs = self::deepCloneAttrs($attrs); + $copy->rawOpen = null; + $copy->rawClose = null; + $copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + + return $copy; + } +``` + +`Container.php` – neue Methoden nach `removeChild()`: + +```php + /** Puts $new where the child $old is; the text around it stays. */ + public function replaceChild(Block $old, Block $new): void + { + $this->guardMutable(); + $this->parts[$this->partPosition($old)] = $new; + $this->touched(); + } + + /** + * "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved + * through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture + * finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched. + */ + public function unifyLineBreaks(): void + { + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $part->unifyLineBreaks(); + } elseif (trim($part) === '' && str_contains($part, "\r\n")) { + $this->parts[$position] = str_replace("\r\n", "\n", $part); + } + } + } +``` + +`src/Integrations/Divi/BlockTree/GlobalInstance.php`: + +```php +name() !== self::BLOCK) { + return $block->name(); + } + + $name = $block->attrs()['blockName'] ?? ''; + + return is_string($name) ? $name : ''; + } + + /** + * The instance the Visual Builder writes for library entry $libraryId whose content root is + * $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested; + * fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and + * localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out + * when empty –, the root's children as snapshot with "globalParent": "" appended to every + * block below. A void root gives a void instance. $root is not changed. + */ + public static function of(Block $root, int $libraryId): Block + { + $id = (string) $libraryId; + $local = $root->attrs(); + ksort($local); + $attrs = ['globalModule' => $id, 'blockName' => $root->name()]; + if ($local !== []) { + $attrs['localAttrs'] = $local; + } + + $instance = $root->copyAs(self::BLOCK, $attrs); + foreach ($instance->children() as $child) { + self::markParent($child, $id); + } + + return $instance; + } + + /** + * Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1 + * PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every + * divi/global-layout at or below $block becomes its blockName with localAttrs as attributes, + * and globalParent disappears from every block. Other opaque blocks stay. Changes $block in + * place (pass a copy) and returns the result – a new block when $block itself was an instance. + */ + public static function deglobalize(Block $block): Block + { + if ($block->name() === self::BLOCK) { + $name = self::effectiveName($block); + if ($name === '') { + return $block; + } + $local = $block->attrs()['localAttrs'] ?? []; + $block = $block->copyAs($name, is_array($local) ? $local : []); + } + + if ($block->isOpaque()) { + return $block; + } + + if (array_key_exists('globalParent', $block->attrs())) { + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $block->setAttrs($attrs); + } + + foreach ($block->children() as $child) { + $plain = self::deglobalize($child); + if ($plain !== $child) { + $block->replaceChild($child, $plain); + } + } + + return $block; + } + + private static function markParent(Block $block, string $id): void + { + if ($block->isOpaque()) { + return; + } + + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $attrs['globalParent'] = $id; + $block->setAttrs($attrs); + foreach ($block->children() as $child) { + self::markParent($child, $id); + } + } +} +``` + +`src/Integrations/Divi/BlockTree/LibraryContent.php`: + +```php +parse($content); + $document->unifyLineBreaks(); + + return $document; + } + + /** post_content of a library entry holding $node, framed like the builder's "add to library". */ + public static function wrap(Block $node): string + { + return '' . Serializer::block($node) . ''; + } + + /** @return list Top-level nodes: the children of the first divi/placeholder, else of the document. */ + public static function roots(Document $document): array + { + foreach ($document->children() as $block) { + if ($block->isPlaceholder()) { + return $block->children(); + } + } + + return $document->children(); + } + + /** Whether $block is or contains a global module. */ + public static function containsGlobal(Block $block): bool + { + if ($block->isGlobal()) { + return true; + } + + foreach ($block->children() as $child) { + if (self::containsGlobal($child)) { + return true; + } + } + + return false; + } + + /** + * Why the roots $after may not replace $before, or null. Instances on pages expect their + * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the + * same type; a layout entry (or one with several roots) holds sections only. A global entry + * contains no global modules (F10: Divi forbids nesting). + * + * @param list $before + * @param list $after + */ + public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string + { + if ($after === []) { + return 'Der Bibliothekseintrag darf nicht leer werden.'; + } + + if ($layoutType === 'layout' || count($before) !== 1) { + foreach ($after as $root) { + if (GlobalInstance::effectiveName($root) !== 'divi/section') { + return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.'; + } + } + } else { + $expected = GlobalInstance::effectiveName($before[0]); + if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) { + return sprintf( + 'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).', + $expected + ); + } + } + + if ($global) { + foreach ($after as $root) { + if (self::containsGlobal($root)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung).'; + } + } + } + + return null; + } +} +``` + +`TreeEditor.php` – Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`; in `insert()`: + +```php + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); +``` + +und in `move()`: + +```php + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node)); +``` + +Klassen-Docblock von `TreeEditor` um „globale Instanzen gelten als ihr Wurzeltyp (blockName)“ ergänzen. + +`tests/Fixtures/divi/stage3/README.md` – unter „Format-Befunde“ als Punkt 9 anfügen: + +```markdown +9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`. +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest|ParserTest|SerializerTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/BlockTree src/Integrations/Divi/Compose/TreeEditor.php tests/Unit/Integrations/Divi/BlockTree tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Fixtures/divi/stage3/README.md +``` +Commit wie in den Global Constraints mit der Nachricht `feat: global instance in the builder's format, deglobalize, library content rules`. + +--- + +### Task 8: Bibliothekseinträge mit den Divi-Werkzeugen lesen – `LibraryAccess`, `DiviPageLoader` + +E1/E3: `lit-divi-outline` und `lit-divi-node-get` nehmen die ID eines Bibliothekseintrags an. `LibraryAccess::item()` prüft zuerst, ob die ID ein `et_pb_layout` ist; sonst geht es unverändert über `PostGuard` weiter (Seiten bleiben wie bisher, `ContentSchema::EXCLUDED` bleibt). Schreiben folgt in Task 9 – bis dahin laufen Schreibzugriffe auf Bibliotheks-IDs weiter über `PostGuard` und enden in `not_found`. + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryAccess.php` +- Modify: `src/Integrations/Divi/Pages/DiviPage.php` +- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Create: `tests/Support/LibraryFixtures.php` +- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `DiviLibraryStore`, `DiviRoles` (Task 6), `PostGuard::id()`, `Errors`. +- Produces: + - `class LibraryAccess { __construct(DiviLibraryStore $store, DiviRoles $roles, ?Closure $writable = null); store(): DiviLibraryStore; roles(): DiviRoles; item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null; canList(): ?WP_Error; canRead(WP_Post $item): ?WP_Error; isGlobal(WP_Post $item): bool }` (nicht final; `canEdit`, `canSave`, `canInsert` folgen in Task 9/12/13) + - `DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false)`, `DiviPage::isLibraryItem(): bool`; `editUrl()` liefert für Bibliothekseinträge `get_edit_post_link($id, 'raw')`. + - `DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null)`, `DiviPageLoader::library(): ?LibraryAccess`. + - `LibraryFixtures` (Tests): `ITEMS`, `item(int $id, array $fields = []): WP_Post`, `page(string $content, array $fields = []): WP_Post`, `given(array $posts, array $denied = []): void`, `store(): DiviLibraryStore`, `roles(array $deniedRoles = []): DiviRoles`, `access(array $deniedRoles = [], bool $writable = true, ?DiviLibraryStore $store = null): LibraryAccess`, `loader(?LibraryAccess $access = null): DiviPageLoader`. + +- [ ] **Step 1: Test-Helfer und failing tests schreiben** + +`tests/Support/LibraryFixtures.php`: + +```php + ID => [fixture file, layout type, global]. */ + public const ITEMS = [ + 1054 => ['library--layout-ganze-seite.html', 'layout', false], + 1055 => ['library--library-abschnitt.html', 'section', false], + 1056 => ['library--library-zeile.html', 'row', false], + 1057 => ['library--library-modul.html', 'module', false], + 1058 => ['library-global--globaler-abschnitt.html', 'section', true], + 1059 => ['library-global--globales-modul.html', 'module', true], + ]; + + /** @param array $fields */ + public static function item(int $id, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => DiviLibraryStore::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => '[Fixture] ' . $id, + 'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]), + ], $fields)); + } + + /** @param array $fields */ + public static function page(string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => self::PAGE, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Start', + 'post_content' => $content, + ], $fields)); + } + + /** + * WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied. + * + * @param list $posts + * @param list $denied + */ + public static function given(array $posts, array $denied = []): void + { + $byId = []; + foreach ($posts as $post) { + $byId[(int) $post->ID] = $post; + } + Functions\when('get_post')->alias( + static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true) + ); + Functions\when('get_post_type_object')->justReturn(null); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf( + 'https://kunde.test/wp-admin/post.php?post=%d&action=edit', + $post instanceof WP_Post ? $post->ID : (int) $post + )); + Functions\when('add_query_arg')->alias( + static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value + ); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + } + + /** A store that answers isGlobal()/layoutType() from ITEMS (no database). */ + public static function store(): DiviLibraryStore + { + $store = Mockery::mock(DiviLibraryStore::class)->makePartial(); + $store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false); + $store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null); + + return $store; + } + + /** @param list $deniedRoles Divi role options the user lacks. */ + public static function roles(array $deniedRoles = []): DiviRoles + { + return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true)); + } + + /** @param list $deniedRoles */ + public static function access( + array $deniedRoles = [], + bool $writable = true, + ?DiviLibraryStore $store = null + ): LibraryAccess { + return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); + } + + public static function loader(?LibraryAccess $access = null): DiviPageLoader + { + return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`: + +```php +item(1055, false)); + self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business'); + self::assertNull($access->item(9999, false)); + self::assertNull($access->item('abc', false)); + self::assertNull($access->canList()); + } + + public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void + { + $item = LibraryFixtures::item(1055); + + LibraryFixtures::given([$item], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item]); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item], ['edit_post']); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message()); + } + + public function testTrashedItemsCanBeRead(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'trash']); + LibraryFixtures::given([$item]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false)); + } + + public function testGlobalFromTheStore(): void + { + $access = LibraryFixtures::access(); + + self::assertTrue($access->isGlobal(LibraryFixtures::item(1058))); + self::assertFalse($access->isGlobal(LibraryFixtures::item(1055))); + } +} +``` + +In `DiviPageLoaderTest`: + +```php + public function testLoadsALibraryItemForReading(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $page = LibraryFixtures::loader()->load(1058, false); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + self::assertSame('divi/section', $page->index->find('s0')?->name()); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl()); + } + + public function testLibraryItemsNeedTheLibraryAccess(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); + self::assertToolError( + (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), + 'forbidden' + ); + } +``` + +(Imports: `LibraryFixtures`, `ContentSchema`, `Parser`, `Mockery`.) + +In `DiviReadAbilitiesTest`: + +```php + public function testOutlineOfALibraryItem(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1059]); + + self::assertSame(['m0'], array_column($result['nodes'], 'id')); + self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); + } +``` + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest'` +Expected: FAIL (`LibraryAccess` fehlt) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/LibraryAccess.php`: + +```php +store; + } + + public function roles(): DiviRoles + { + return $this->roles; + } + + /** + * The library entry $postId after the checks for reading, or their refusal; null when $postId + * names no library entry (the caller goes on with PostGuard). + */ + public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null + { + $id = PostGuard::id($postId); + $post = $id === null ? null : get_post($id); + if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) { + return null; + } + + return $this->canRead($post) ?? $post; + } + + /** Listing the library: edit_posts and the Divi role option divi_library. */ + public function canList(): ?WP_Error + { + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.'); + } + + return $this->roles->refusal('divi_library'); + } + + /** Reading one entry: canList() and edit_post on the entry. */ + public function canRead(WP_Post $item): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + return current_user_can('edit_post', (int) $item->ID) + ? null + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); + } + + public function isGlobal(WP_Post $item): bool + { + return $this->store->isGlobal((int) $item->ID); + } +} +``` + +(`$forWrite` wird in Task 9 ausgewertet; bis dahin ruft `DiviPageLoader` `item()` nur zum Lesen auf.) + +`DiviPage.php` – Konstruktor und `editUrl()`: + +```php + public function __construct( + public readonly WP_Post $post, + public readonly Document $document, + public readonly NodeIndex $index, + private bool $libraryItem = false + ) { + } + + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ + public function isLibraryItem(): bool + { + return $this->libraryItem; + } + + public function editUrl(): string + { + // A library entry has no public permalink (public: false); Divi's library list opens it here. + return $this->libraryItem + ? (string) get_edit_post_link($this->post, 'raw') + : self::builderUrl($this->post); + } +``` + +`DiviPageLoader.php` – Konstruktor, `library()` und Anfang von `load()` (Import `use LIT\MCP\Integrations\Divi\Library\LibraryAccess;`, `use WP_Post;`): + +```php + public function __construct( + private ContentSchema $schema, + private Parser $parser = new Parser(), + private ?LibraryAccess $library = null + ) { + } + + /** The library access the Divi tools use, null when library entries are not reachable. */ + public function library(): ?LibraryAccess + { + return $this->library; + } + + /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ + public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error + { + // Library entries (E1): only for reading so far, and only with a library access. + $item = $forWrite ? null : $this->library?->item($postId, false); + $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + if ($post instanceof WP_Error) { + return $post; + } +``` + +und am Ende von `load()`: + +```php + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); +``` + +`DiviIntegration.php` – Bibliothekszugang als Feld (Imports `DiviLibraryStore`, `DiviRoles`, `LibraryAccess`, `Parser`): + +```php + private LibraryAccess $access; + + public function __construct(private ContentSchema $schema, private ChangeLog $log) + { + $this->modules = new DiviModules(); + $this->design = new DesignSystemReader(); + $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles()); + } +``` + +und in `abilities()`: + +```php + $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages/DiviPage.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/DiviIntegration.php tests/Support/LibraryFixtures.php tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: Divi read tools accept library entries (library access, E1/E3)`. + +--- + +### Task 9: Bibliothekseinträge schreiben – Formregeln, globale Einträge mit Probelauf, `confirm` und Sicherung + +E2 und Antwort F2: Die Divi-Schreibwerkzeuge (`lit-divi-insert`, `-update`, `-move`, `-delete`, `lit/divi-duplicate-node`) ändern Bibliothekseinträge wie Seiten. Zusätzlich (`GlobalEdits`, aufgerufen aus `DiviWriter::commit()` nach der vollständig validierten Änderung): Der Eintrag behält seine Form (`LibraryContent::shapeViolation`); ist er global, wird ohne `confirm: true` nichts gespeichert und die Antwort nennt die betroffenen Seiten (Probelauf, `GlobalUsages` sucht `"globalModule":""` wie Divi); mit `confirm: true` sichert `DiviSaver` wie immer eine Revision im kses-Fenster, bei abgeschalteten Revisionen legt `GlobalEdits` vorher eine Sicherung an (`PostCloner::backup`), danach leert `DiviSaver` seitenweit den CSS- und Theme-Builder-Cache (`SaveScope::GlobalItem`). Bibliothekseinträge bekommen keine Divi-Flags (Fixture-Befund 4). Das Gruppen-Gate: ist „Divi-Bibliothek“ auf „nur lesen“ oder deaktiviert, sind Bibliothekseinträge für alle Divi-Werkzeuge nur lesbar. + +**Files:** +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`item()` für Schreiben, `canEdit()`, `writableGate()`, `GROUP`) +- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (Schreiben, `writable()`) +- Create: `src/Integrations/Divi/Pages/SaveScope.php` +- Modify: `src/Integrations/Divi/Pages/DiviSaver.php` +- Create: `src/Integrations/Divi/Library/GlobalUsages.php` +- Create: `src/Integrations/Divi/Library/GlobalEdits.php` +- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (`save()`-Scope, `commit()`, `notice()`) +- Modify: `src/Integrations/Divi/Pages/DiviWriteAbility.php` (`precheck()`, `commit`, `notice`) +- Modify: `src/Integrations/Divi/Pages/DiviSchema.php` (`confirm`) +- Modify: `src/Abilities/ExposurePolicy.php` (`groupWritable()`) +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php` +- Create: `tests/Stubs/EtCorePageResource.php`; Modify: `tests/bootstrap.php`, `tests/Support/DiviWriterFixture.php` +- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `…/Library/GlobalUsagesTest.php`, `…/Library/GlobalEditsTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `…/Pages/DiviSaverTest.php`, `tests/Unit/Abilities/ExposurePolicyTest.php` + +**Interfaces:** +- Consumes: `LibraryContent::load/roots/shapeViolation` (Task 7), `LibraryAccess`, `LibraryFixtures` (Task 8), `PostCloner::backup(WP_Post): int`, `Errors::confirmRequired/clientMessage/codeOf/unsupported/invalidInput`. +- Produces: + - `LibraryAccess::GROUP = 'divi-library'`, `LibraryAccess::canEdit(WP_Post $item): ?WP_Error`, `LibraryAccess::writableGate(): ?WP_Error`; `item($id, true)` prüft `canEdit()`. + - `enum SaveScope { Page, LibraryItem, GlobalItem; setsFlags(): bool; clearsSiteCss(): bool }` + - `DiviSaver::save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array`, `DiviSaver::saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): list` + - `class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list, count: int} }` (nicht final) + - `final class GlobalEdits { __construct(LibraryAccess $access, GlobalUsages $usages, ?Closure $backup = null, Parser $parser = new Parser()); prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array}|WP_Error; notice(DiviPage $page, WP_Error $error): WP_Error }` + - `DiviWriter::__construct(…, DiviComposition $composition, ?GlobalEdits $globals = null)`, `DiviWriter::save(…, SaveScope $scope = SaveScope::Page)`, `DiviWriter::commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error`, `DiviWriter::notice(DiviPage $page, WP_Error $error): WP_Error` + - `DiviWriteAbility::precheck(array $input): ?WP_Error` (protected, Standard `null`) + - `ExposurePolicy::groupWritable(string $group): bool` + - `DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?Closure $libraryWritable = null)` + - `DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null)` + +- [ ] **Step 1: Stub und failing tests schreiben** + +`tests/Stubs/EtCorePageResource.php`: + +```php + */ + public static array $removed = []; + + public static function remove_static_resources($postId, $owner, $force = false): void + { + self::$removed[] = [$postId, $owner, (bool) $force]; + } + } +} +``` + +In `tests/bootstrap.php` nach `RequestsException.php` ergänzen: `require_once __DIR__ . '/Stubs/EtCorePageResource.php';` + +`DiviWriterFixture::writer()` um den Parameter `?GlobalEdits $globals = null` (letzter Parameter, Import `LIT\MCP\Integrations\Divi\Library\GlobalEdits`) erweitern und an `new DiviWriter(…, self::composition($design, $unfiltered), $globals)` durchreichen. + +In `DiviSaverTest`: + +```php + public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void + { + \ET_Core_PageResource::$removed = []; + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + $cleared = []; + Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void { + $cleared[] = $what; + }); + + (new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame([], $cleared); + + \ET_Core_PageResource::$removed = []; + (new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem); + self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame(['all'], $cleared); + } + + public function testSaveNewOfALibraryItemSetsNoFlags(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU'])); + + self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem)); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + } +``` + +(Import `use LIT\MCP\Integrations\Divi\Pages\SaveScope;`.) + +In `LibraryAccessTest`: + +```php + public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void + { + LibraryFixtures::given([ + LibraryFixtures::item(1057), + LibraryFixtures::item(1059), + LibraryFixtures::item(1055, ['post_status' => 'trash']), + ]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true)); + self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden'); + $readonly = LibraryFixtures::access([], false)->item(1057, true); + self::assertToolError($readonly, 'forbidden'); + self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message()); + self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true)); + self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true)); + } +``` + +In `DiviPageLoaderTest`: + +```php + public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access()); + + $page = $loader->loadWritable(1057); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + } +``` + +In `ExposurePolicyTest`: + +```php + public function testGroupWritable(): void + { + self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library')); + } +``` + +`tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php`: + +```php +makePartial(); + $queries = []; + $db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string { + $queries[] = $sql; + + return '3'; + }); + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { + $queries[] = $sql; + + return [ + ['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'], + ['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'], + ]; + }); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061); + + $usages = (new GlobalUsages($db))->of(1058); + + self::assertSame(3, $usages['count']); + self::assertSame( + [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'], + ['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'], + ], + $usages['items'] + ); + self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); + self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]); + self::assertStringContainsString('LIMIT 50', $queries[1]); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php`: + +```php + Content and scope handed to the saver. */ + private array $saved = []; + + private int $backups = 0; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_revisions_enabled')->justReturn(true); + } + + private function usages(): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn([ + 'items' => [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], + ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], + ], + 'count' => 2, + ]); + + return $usages; + } + + /** @param list $deniedRoles */ + private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $this->saved[] = [$content, $scope]; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $access = LibraryFixtures::access($deniedRoles, $writable); + $design = DiviWriterFixture::designReader(); + $globals = new GlobalEdits($access, $this->usages(), function (WP_Post $item): int { + ++$this->backups; + + return 1071; + }); + + return new DiviWriter( + LibraryFixtures::loader($access), + $saver, + DiviWriterFixture::log(), + $design, + DiviWriterFixture::composition($design), + $globals + ); + } + + private static function rev(int $id): string + { + return Rev::of((string) LibraryFixtures::item($id)->post_content); + } + + /** @return array */ + private static function relabel(int $id, bool $confirm = false): array + { + return array_merge( + ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]], + $confirm ? ['confirm' => true] : [] + ); + } + + public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + + $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + + self::assertTrue($result['ok']); + self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]); + self::assertStringStartsWith(''; + + self::assertSame([], self::guard()->problems($index->find('s0'))); + self::assertSame([], self::guard()->problems($index->find('g0'))); + self::assertSame([], self::guard()->problems(self::block($link))); + } + + /** @return array */ + public static function refused(): array + { + $ref = static fn (string $name, string $settings): string => '"$variable({"type":"content",' + . '"value":{"name":"' . $name . '","settings":' . $settings . '}})$"'; + + return [ + 'custom css' => [ + '', + '"css"', + ], + 'html attributes' => [ + '', + 'module.decoration.attributes', + ], + 'code module' => ['', 'Code-Modul'], + 'script in before' => [ + '', + 'nicht schreiben darf', + ], + 'protected meta' => [ + '', + 'custom_meta__edit_lock', + ], + 'css in the local attributes of an instance' => [ + '', + '"css"', + ], + 'code module deep inside a section' => [ + '' + . '', + 'Code-Modul', + ], + ]; + } + + /** @dataProvider refused */ + public function testRefusesBuilderOnlyValuesForUsersWithoutUnfilteredHtml(string $markup, string $problem): void + { + $problems = self::guard()->problems(self::block($markup)); + + self::assertNotSame([], $problems); + self::assertStringContainsString($problem, implode(' | ', $problems)); + self::assertSame([], self::guard(true)->problems(self::block($markup)), 'unfiltered_html may carry it'); + } +} +``` + +In `DiviCompositionTest` ergänzen: + +```php + public function testStoredGuardSharesTheCleanerAndReferences(): void + { + $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); + + self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); + self::assertSame( + [], + $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) + ); + } +``` + +(Imports `StoredNodeGuard`, `Block`, `DiviWriterFixture`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest'` +Expected: FAIL (`StoredNodeGuard` fehlt) + +- [ ] **Step 3: Implementieren** + +`VariableRef.php` – nach `typeOf()`: + +```php + /** + * The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares + * a stored reference with canonical(): different settings mean cleaning would change them. + * + * @return array + */ + public static function settingsOf(string $value): array + { + try { + $payload = self::payloadOf($value); + } catch (InvalidArgumentException) { + return []; + } + + $settings = $payload['value']['settings'] ?? []; + + return is_array($settings) ? $settings : []; + } +``` + +`src/Integrations/Divi/Compose/StoredNodeGuard.php`: + +```php + German problems; [] when $root may move to the target post. */ + public function problems(Block $root): array + { + if ($this->sanitizer->allowsUnfilteredHtml()) { + return []; + } + + $problems = []; + $this->walk($root, $problems); + + return array_values(array_unique($problems)); + } + + /** @param list $problems */ + private function walk(Block $block, array &$problems): void + { + $module = GlobalInstance::effectiveName($block); + if (in_array($module, self::CODE_MODULES, true)) { + $problems[] = sprintf( + '%s: Code-Modul – nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html).', + $module + ); + } + + $attrs = $block->attrs(); + if ($block->name() === GlobalInstance::BLOCK) { + $attrs = is_array($attrs['localAttrs'] ?? null) ? $attrs['localAttrs'] : []; + } + + foreach (AttrMap::flatten($attrs) as $path => $value) { + try { + $this->cleaner->guardPath((string) $path); + } catch (InvalidArgumentException $e) { + $problems[] = $e->getMessage(); + continue; + } + $this->references($module, (string) $path, $value, null, $problems); + } + + foreach ($block->children() as $child) { + $this->walk($child, $problems); + } + } + + /** @param list $problems */ + private function references(string $module, string $path, mixed $value, ?string $key, array &$problems): void + { + if (is_array($value)) { + foreach ($value as $childKey => $item) { + $isField = is_string($childKey) + && ! in_array($childKey, AttrMap::BREAKPOINTS, true) + && ! in_array($childKey, self::STATE_KEYS, true); + $this->references($module, $path, $item, $isField ? $childKey : $key, $problems); + } + + return; + } + + if (! is_string($value) || ! VariableRef::contains($value)) { + return; + } + + try { + $url = $this->cleaner->kindOf($module, $path, $key) === ValueKind::Url; + $canonical = $this->references->canonical($value, $this->sanitizer, $url); + } catch (InvalidArgumentException $e) { + $problems[] = sprintf('%s: %s', $path, $e->getMessage()); + + return; + } + + if (self::normalized(VariableRef::settingsOf($canonical)) !== self::normalized(VariableRef::settingsOf($value))) { + $problems[] = sprintf( + '%s: Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – nur im Divi-Builder ' + . 'übertragbar.', + $path + ); + } + } + + /** + * @param array $settings + * @return array Keys sorted, scalars as strings (post_id 839 = "839"). + */ + private static function normalized(array $settings): array + { + ksort($settings); + + return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); + } +} +``` + +`DiviComposition` (Import `StoredNodeGuard`): + +```php + /** E6: the check for stored nodes that move into this composition's entry. */ + public function storedGuard(): StoredNodeGuard + { + return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer); + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest|VariableRefTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Compose/StoredNodeGuard.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)`. + +--- + +### Task 12: `lit/library-save` – Knoten als Bibliothekseintrag (E4) + +E4: Das Plugin legt Einträge selbst an – kein `et_pb_submit_layout` (im REST-Kontext nicht geladen, speichert mit kses, F2), keine Builder-Route (Nonce, Preset-Import, F3). `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Bibliotheksentwürfe), Terme und Meta wie `et_pb_submit_layout` (verglichen mit den echten Builder-Einträgen der Fixtures; `_et_pb_row_layout` wird nicht geschrieben – Divi selbst speichert dort `"[object Object]"`, Befund 5), danach der Inhalt über `DiviSaver::saveNew` (`SaveScope::LibraryItem`, ohne Divi-Flags, Befund 4) als `divi/placeholder`-Rahmen um die unveränderten Bytes des Knotens. Alles oder nichts: jeder Fehler löscht den neuen Eintrag endgültig. Der Knoten auf der Seite bleibt unverändert (das Verknüpfen ist `lit/library-make-global`, Etappe 3b). + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryItemSpec.php` +- Modify: `src/Integrations/Divi/Library/DiviLibraryStore.php` (`create()`, `delete()`) +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canSave()`) +- Create: `src/Integrations/Divi/Library/LibrarySaveAbility.php` +- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`, `…/DiviLibraryStoreTest.php`, `…/LibraryAccessTest.php`, `…/LibrarySaveAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `LibraryContent::wrap/containsGlobal`, `NodeSummary::globalRef`, `ModuleCatalog::has/category`, `DiviSaver::saveNew(…, SaveScope::LibraryItem)`, `StoredNodeGuard` über `DiviWriter::composition($page)->storedGuard()`, `PostSaveFollowUp`, `Errors::writeFailed/partialWrite/revMismatch`. +- Produces: + - `final class LibraryItemSpec { __construct(string $title, string $layoutType, bool $global, string $moduleWidth, ?string $moduleType, string $builtFor, array $categories = [], array $tags = []); static forNode(Block $node, ModuleCatalog $catalog, string $title, bool $global, string $builtFor): self|string; withTerms(array $categories, array $tags): self; terms(): array>; meta(): array }` + - `DiviLibraryStore::create(LibraryItemSpec $spec): int` (wirft `RuntimeException`, löscht bei Teilfehlern), `DiviLibraryStore::delete(int $id): bool` + - `LibraryAccess::canSave(): ?WP_Error` + - `final class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) }` – `lit/library-save`, indirekt, schreibend (nicht idempotent); Ergebnis `ToolResult` + `library_id`, `tree_rev`. + - `LibraryIntegration::__construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules)` + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`: + +```php +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))) + ->find($id); + } + + private static function libraryRoot(string $file): Block + { + return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0]; + } + + /** @return array Fixture role => [node source, global]. */ + public static function builderEntries(): array + { + return [ + 'library--library-abschnitt' => ['page:s1', false], + 'library--library-zeile' => ['page:s1.r0', false], + 'library--library-modul' => ['page:s1.r0.c0.m0', false], + 'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true], + 'library-global--globales-modul' => ['library:library-global--globales-modul.html', true], + ]; + } + + /** + * Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry), + * without Divi's broken _et_pb_row_layout ("[object Object]", finding 5). + * + * @dataProvider builderEntries + */ + public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void + { + [$kind, $ref] = explode(':', $source, 2); + $node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref); + $role = (string) $this->dataName(); + $json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true); + + $spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page'); + + self::assertInstanceOf(LibraryItemSpec::class, $spec); + $expectedTerms = []; + foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) { + $expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug'); + } + self::assertEquals($expectedTerms, $spec->terms()); + $expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']); + unset($expectedMeta['_et_pb_row_layout']); + self::assertEquals($expectedMeta, $spec->meta()); + } + + public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void + { + $catalog = DiviFixtures::catalog(); + + self::assertStringContainsString( + 'bereits ein globales Modul (Bibliothekseintrag 1058)', + (string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'nur Abschnitte, Zeilen und Module', + (string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page') + ); + self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page')); + } + + public function testFullwidthAndTerms(): void + { + $section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]); + + $spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page'); + + self::assertSame(['fullwidth'], $spec->terms()['module_width']); + self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']); + self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms()); + } +} +``` + +In `DiviLibraryStoreTest`: + +```php + public function testCreatesAPublishedEntryWithTermsAndMeta(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + $inserted = []; + Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int { + $inserted = $data; + + return 2001; + }); + $terms = []; + Functions\when('wp_set_object_terms')->alias(static function (int $id, array $values, string $taxonomy) use (&$terms): array { + $terms[$taxonomy] = $values; + + return [1]; + }); + $meta = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool { + $meta[$key] = $value; + + return true; + }); + Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $meta[$key] ?? ''); + $spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page'); + + self::assertSame(2001, (new DiviLibraryStore())->create($spec)); + self::assertSame( + ['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''], + $inserted + ); + self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms); + self::assertSame('[]', $meta['_et_pb_excluded_global_options']); + } + + public function testAFailedStepDeletesTheNewEntry(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.')); + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001])); + + $this->expectException(\RuntimeException::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page')); + } + + public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void + { + Functions\when('taxonomy_exists')->justReturn(false); + Functions\expect('wp_insert_post')->never(); + + $this->expectException(\RuntimeException::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page')); + } +``` + +(Import `LibraryItemSpec`. Die Stubs aus `setUp()` für `get_post_meta` werden im ersten Test per `Functions\when` überschrieben.) + +In `LibraryAccessTest`: + +```php + public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void + { + LibraryFixtures::given([]); + self::assertNull(LibraryFixtures::access()->canSave()); + + LibraryFixtures::given([], ['publish_posts']); + self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden'); + + LibraryFixtures::given([]); + self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden'); + } +``` + +`tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php`: + +```php + */ + private array $specs = []; + + /** @var list */ + private array $contents = []; + + private int $deleted = 0; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + LibraryFixtures::given([$this->page]); + Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture' + ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy]) + : false); + $this->log = new FakeChangeLog(4711); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility + { + $store = LibraryFixtures::store(); + $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { + $this->specs[] = $spec; + + return 2001; + }); + $store->allows('delete')->andReturnUsing(function (int $id): bool { + ++$this->deleted; + + return true; + }); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array { + if ($layoutFailure !== null) { + throw $layoutFailure; + } + $this->contents[] = [$id, $content, $scope]; + LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]); + + return []; + }); + $access = LibraryFixtures::access($deniedRoles, $writable, $store); + $saved = []; + $failures = []; + + return new LibrarySaveAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::writer($saved, $failures, false, LibraryFixtures::loader($access)), + $saver, + $this->log, + DiviWriterFixture::modules() + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge( + ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], + $extra + ); + } + + public function testSavesASectionLikeTheBuilder(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Fixture']])); + + self::assertTrue($result['ok']); + self::assertSame(2001, $result['library_id']); + self::assertSame('section', $this->specs[0]->layoutType); + self::assertSame([26], $this->specs[0]->terms()['layout_category']); + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), + $this->contents[0][1] + ); + self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]); + self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']); + self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); + self::assertSame('2001', $this->log->records[0]['object_id']); + } + + public function testSavesAGlobalModule(): void + { + $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global'])); + + self::assertTrue($result['ok']); + self::assertTrue($this->specs[0]->global); + self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']); + self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']); + } + + public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testStaleSourceTreeRevCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch'); + self::assertSame([], $this->specs); + } + + public function testNeedsPublishPostsAndTheDiviRole(): void + { + self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden'); + LibraryFixtures::given([$this->page], ['publish_posts']); + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertSame([], $this->specs); + } + + public function testEditorCannotSaveACodeModule(): void + { + $this->page = LibraryFixtures::page( + '' + . '' + . '' + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result))); + self::assertSame([], $this->specs); + } + + public function testUnknownCategoryCreatesNothing(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testFailedLayoutWriteDeletesTheEntry(): void + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + } finally { + ini_set('error_log', (string) $previous); + @unlink($log); + } + + self::assertToolError($result, 'write_failed'); + self::assertSame(1, $this->deleted); + } +} +``` + +In `DiviIntegrationTest::testProvidesTheLibraryGroup` die erwartete Liste auf `['lit/library-list', 'lit/library-save']` erweitern. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest'` +Expected: FAIL (`LibraryItemSpec`, `create`, `canSave`, `LibrarySaveAbility` fehlen) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/LibraryItemSpec.php`: + +```php + $categories layout_category term IDs. + * @param list $tags layout_tag term IDs. + */ + public function __construct( + public readonly string $title, + public readonly string $layoutType, + public readonly bool $global, + public readonly string $moduleWidth, + public readonly ?string $moduleType, + public readonly string $builtFor, + public readonly array $categories = [], + public readonly array $tags = [] + ) { + } + + /** + * The entry that holds $node, or the German reason it cannot be saved: sections, rows and + * modules only (Divi's "add to library"); a global module is already an entry; a global entry + * holds no global module (F10). + */ + public static function forNode( + Block $node, + ModuleCatalog $catalog, + string $title, + bool $global, + string $builtFor + ): self|string { + $ref = NodeSummary::globalRef($node); + if ($ref !== null) { + return sprintf( + 'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert ' + . 'einfügen statt erneut speichern.', + $ref['id'] + ); + } + + $name = $node->name(); + if ($name === 'divi/section') { + $type = 'section'; + $width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular'; + } elseif ($name === 'divi/row') { + $type = 'row'; + $width = 'regular'; + } elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) { + $type = 'module'; + $width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular'; + } else { + return sprintf( + '%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).', + $name + ); + } + + if ($global && LibraryContent::containsGlobal($node)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung); als normalen Eintrag speichern.'; + } + + return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor); + } + + /** + * @param list $categories + * @param list $tags + */ + public function withTerms(array $categories, array $tags): self + { + return new self( + $this->title, + $this->layoutType, + $this->global, + $this->moduleWidth, + $this->moduleType, + $this->builtFor, + $categories, + $tags + ); + } + + /** @return array> Taxonomy => slugs (system terms) or term IDs. */ + public function terms(): array + { + $terms = [ + 'scope' => [$this->global ? 'global' : 'non_global'], + 'layout_type' => [$this->layoutType], + 'module_width' => [$this->moduleWidth], + ]; + if ($this->categories !== []) { + $terms['layout_category'] = $this->categories; + } + if ($this->tags !== []) { + $terms['layout_tag'] = $this->tags; + } + + return $terms; + } + + /** @return array */ + public function meta(): array + { + $meta = []; + if ($this->moduleType !== null) { + $meta['_et_pb_module_type'] = $this->moduleType; + } + if ($this->global && $this->layoutType === 'module') { + $meta['_et_pb_excluded_global_options'] = '[]'; + } + $meta['_et_pb_built_for_post_type'] = $this->builtFor; + $meta['_et_pb_template_type'] = $this->layoutType; + + return $meta; + } +} +``` + +`DiviLibraryStore` – neue Methoden (Imports `RuntimeException`, `WP_Error`): + +```php + /** + * A published entry with the terms and meta of $spec and empty content (E4; the layout follows + * through DiviSaver::saveNew()). All or nothing: when a term or meta write fails, the entry is + * deleted again before the exception leaves. + * + * @throws RuntimeException + */ + public function create(LibraryItemSpec $spec): int + { + $missing = $this->missingTaxonomies(); + if ($missing !== []) { + throw new RuntimeException('Divi Library taxonomies not registered: ' . implode(', ', $missing)); + } + + $id = wp_insert_post(wp_slash([ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $spec->title, + 'post_content' => '', + ]), true); + if ($id instanceof WP_Error || (int) $id <= 0) { + throw new RuntimeException( + 'wp_insert_post refused the library entry: ' . ($id instanceof WP_Error ? $id->get_error_message() : '0') + ); + } + + $id = (int) $id; + try { + foreach ($spec->terms() as $taxonomy => $terms) { + $result = wp_set_object_terms($id, $terms, $taxonomy); + if ($result instanceof WP_Error) { + throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message())); + } + } + foreach ($spec->meta() as $key => $value) { + update_post_meta($id, $key, $value); + if ((string) get_post_meta($id, $key, true) !== $value) { + throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id)); + } + } + } catch (RuntimeException $e) { + $this->delete($id); + throw $e; + } + + return $id; + } + + /** Deletes an entry for good (only this plugin's own, just created entries). */ + public function delete(int $id): bool + { + return (bool) wp_delete_post($id, true); + } +``` + +`LibraryAccess::canSave()`: + +```php + /** + * Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always + * published, F2), the role option save_library and the Tools-tab gate. + */ + public function canSave(): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + $type = get_post_type_object(DiviLibraryStore::POST_TYPE); + $publish = is_object($type) && isset($type->cap->publish_posts) ? (string) $type->cap->publish_posts : 'publish_posts'; + if (! current_user_can($publish)) { + return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).'); + } + + return $this->roles->refusal('save_library') ?? $this->writableGate(); + } +``` + +`src/Integrations/Divi/Library/LibrarySaveAbility.php`: + +```php + 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; + + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function name(): string + { + return 'lit/library-save'; + } + + public function label(): string + { + return 'In der Divi-Bibliothek speichern'; + } + + public function description(): string + { + return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – ' + . 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die ' + . 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit ' + . 'lit-library-insert (mode "global") auf Seiten einfügen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Seite (oder Eintrag) mit dem Knoten.'], + 'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'], + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'scope' => ['type' => 'string', 'enum' => ['normal', 'global'], 'description' => 'Standard: normal.'], + 'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'id', 'title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + ['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']], + ['library_id', 'tree_rev'] + ); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $refusal = $this->access->canSave(); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $refusal = $this->access->canSave(); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.', + DiviPageLoader::OUTLINE_HINT + ); + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + $node = $page->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $page->post); + } + + $title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : ''); + if ($title === '') { + return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']); + } + + $spec = LibraryItemSpec::forNode( + $node, + $this->modules->catalog(), + $title, + ($input['scope'] ?? 'normal') === 'global', + (string) $page->post->post_type + ); + if (is_string($spec)) { + return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]); + } + + $problems = $this->writer->composition($page)->storedGuard()->problems($node); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde angelegt.', + ['id' => implode('; ', $problems)] + ); + } + + $categories = $this->termIds('layout_category', $input['categories'] ?? []); + $tags = $this->termIds('layout_tag', $input['tags'] ?? []); + foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) { + if (is_string($ids)) { + return Errors::invalidInput('Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.', [ + $field => $ids, + ]); + } + } + + return $this->create($page, $spec->withTerms($categories, $tags), LibraryContent::wrap($node), $input); + } + + /** + * @param array $input + * @return array|WP_Error + */ + private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array|WP_Error + { + try { + $libraryId = $this->access->store()->create($spec); + } catch (RuntimeException $e) { + Log::failure('lit/library-save create', $e); + + return Errors::writeFailed('Der Bibliothekseintrag konnte nicht angelegt werden.'); + } + + try { + $warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem); + } catch (Throwable $e) { + Log::failure('lit/library-save layout', $e); + if (! $this->access->store()->delete($libraryId)) { + return Errors::partialWrite('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.', $libraryId); + } + + return Errors::writeFailed('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.'); + } + + $followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings)) + ->afterSave($input); + $created = $followUp->reread(); + $kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : ''); + $followUp->log( + DiviLibraryStore::POST_TYPE, + sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID) + ); + + $result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created) + ->with('library_id', $libraryId) + ->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content)) + ->urls(null, (string) get_edit_post_link($libraryId, 'raw')); + if ($spec->global) { + $result->warning( + 'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert ' + . '(mode "global") einfügen.' + ); + } + + return $result->toArray(); + } + + /** @return list|string Term IDs of existing terms by name, or the problem. */ + private function termIds(string $taxonomy, mixed $names): array|string + { + $ids = []; + foreach (is_array($names) ? $names : [] as $name) { + $term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false; + if (! $term instanceof WP_Term) { + return sprintf( + '„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).', + is_string($name) ? $name : '?' + ); + } + $ids[] = (int) $term->term_id; + } + + return $ids; + } +} +``` + +`LibraryIntegration` – Konstruktor und `abilities()` (Imports `ChangeLog`, `DiviSaver`, `DiviWriter`): + +```php + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DesignSystemReader $design, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function abilities(): array + { + return [ + new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()), + new LibrarySaveAbility($this->access, $this->loader, $this->writer, $this->saver, $this->log, $this->modules), + ]; + } +``` + +`DiviIntegration` – Writer-Fabrik teilen und `library()` anpassen: + +```php + public function library(): LibraryIntegration + { + $loader = $this->loader(); + + return new LibraryIntegration( + $this->access, + $loader, + $this->design, + $this->writer($loader), + new DiviSaver(), + $this->log, + $this->modules + ); + } + + private function writer(DiviPageLoader $loader): DiviWriter + { + return new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + new DiviComposition($this->modules, $this->design), + new GlobalEdits($this->access, new GlobalUsages()) + ); + } +``` + +In `abilities()` `$writer = $this->writer($loader);` statt des bisherigen `new DiviWriter(…)`. + +`docs/guides/overview.md`: „Divi-Bibliothek (`lit/library-list`)“ → „Divi-Bibliothek (`lit/library-list`, `lit/library-save`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/library-save creates library entries like Divi (E4)`. + +--- + +### Task 13: `lit/library-insert` – Kopie oder globale Instanz im Builder-Format (E5, direkt) + +Das einzige neue direkte Tool (22 direkte Tools). `mode` = `global` (Standard für globale Einträge) fügt die Instanz genau so ein, wie der Visual Builder sie schreibt (`GlobalInstance::of`, byte-gleich zum Fixture) – den Inhalt liefert Divi beim Rendern aus der Bibliothek (F7). `mode` = `copy` (Standard sonst) fügt die Wurzeln des Eintrags ohne `divi/placeholder` ein; bei globalen Einträgen vorher entglobalisiert wie Divis `/load` (`GlobalInstance::deglobalize`, F10). Rechte: Ziel wie `lit-divi-insert` (gleiche Sperre, alles oder nichts), dazu `add_library` (E3) und – Audit M2 – nur solange `lit/divi-insert` nicht gesperrt ist. Übernommene Knoten laufen durch `StoredNodeGuard`. + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryInsertAbility.php` +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canInsert()`) +- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php` +- Modify: `docs/guides/overview.md` („Direkt sichtbar:“, Ruling Q2) +- Test: `tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`, `…/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriteAbility` (Task 9: `precheck`, `commit`), `GlobalInstance::of/deglobalize`, `LibraryContent::load/roots`, `DiviComposition::storedGuard/editor`, `TreeEditor::position/insert`, `DiviSchema::write/position`. +- Produces: `LibraryAccess::canInsert(): ?WP_Error`; `final class LibraryInsertAbility extends DiviWriteAbility { __construct(DiviWriter $writer, LibraryAccess $access, ?Closure $diviWritable = null, Parser $parser = new Parser()) }` – `lit/library-insert`, direkt; `LibraryIntegration::__construct(…, DiviModules $modules, ?Closure $diviWritable = null)`; `DiviIntegration::library(?Closure $diviWritable = null)`. + +- [ ] **Step 1: Failing tests schreiben** + +In `LibraryAccessTest`: + +```php + public function testInsertingNeedsAddLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access()->canInsert()); + self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden'); + } +``` + +`tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`: + +```php + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + LibraryFixtures::given(array_merge( + [$this->page], + array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)) + )); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $diviWritable = true): LibraryInsertAbility + { + $access = LibraryFixtures::access($deniedRoles); + $failures = []; + + return new LibraryInsertAbility( + DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)), + $access, + static fn (): bool => $diviWritable + ); + } + + /** @return array */ + private function input(array $extra): array + { + return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra); + } + + private static function fixtureInstance(int $id, string $end): string + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''), $this->saved[0]); + self::assertSame('g0', $result['changed'][0]['id']); + self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings'])); + } + + public function testInsertsAGlobalModuleIntoAColumn(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]); + } + + public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1055])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString("\r", $this->saved[0]); + } + + public function testLayoutCopyInsertsEverySection(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy'])); + + self::assertCount(3, $result['changed']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void + { + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found'); + self::assertSame([], $this->saved); + } + + public function testNeedsAddLibraryAndAWritableDiviGroup(): void + { + self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported'); + self::assertTrue($this->ability()->isDirect()); + } + + public function testEditorCannotInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability()->execute($this->input(['library_id' => 1080])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } +} +``` + +In `DiviIntegrationTest::testProvidesTheLibraryGroup` die Liste auf `['lit/library-list', 'lit/library-save', 'lit/library-insert']` erweitern. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: FAIL (`canInsert`, `LibraryInsertAbility` fehlen; ToolNamingTest bleibt grün, bis das Tool existiert) + +- [ ] **Step 3: Implementieren** + +`LibraryAccess::canInsert()`: + +```php + /** Inserting from the library: canList() and the role option add_library (E3). */ + public function canInsert(): ?WP_Error + { + return $this->canList() ?? $this->roles->refusal('add_library'); + } +``` + +`src/Integrations/Divi/Library/LibraryInsertAbility.php`: + +```php + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'], + 'mode' => ['type' => 'string', 'enum' => ['copy', 'global']], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'library_id'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + protected function precheck(array $input): ?WP_Error + { + $refusal = $this->access->canInsert(); + if ($refusal !== null) { + return $refusal; + } + + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $item = $this->item($input['library_id'] ?? null); + if ($item instanceof WP_Error) { + return $item; + } + + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + $mode = is_string($input['mode'] ?? null) ? $input['mode'] : ($global ? 'global' : 'copy'); + if ($mode === 'global' && ! $global) { + return Errors::invalidInput( + 'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.', + ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden'] + ); + } + + $blocks = $this->blocks($item, $mode, $global); + if ($blocks instanceof WP_Error) { + return $blocks; + } + + $guard = $composition->storedGuard(); + foreach ($blocks as $block) { + $problems = $guard->problems($block); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; ' + . 'nichts wurde gespeichert.', + ['library_id' => implode('; ', $problems)] + ); + } + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, $blocks); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.', + ['library_id' => $violation] + ); + } + + $change = new DiviChange(sprintf( + 'Bibliothekseintrag „%s“ %s eingefügt', + (string) $item->post_title, + $mode === 'global' ? 'als globale Instanz' : 'als Kopie' + )); + foreach ($blocks as $block) { + $change->record($block, 'inserted'); + } + if ($mode === 'global') { + $change->warning(sprintf( + 'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit ' + . 'diesem Modul (lit-divi-outline mit post_id %d).', + $id, + $id + )); + } + + return $change; + } + + /** The active library entry $libraryId the user may read, or the error. */ + private function item(mixed $libraryId): WP_Post|WP_Error + { + $id = PostGuard::id($libraryId); + $item = $id === null ? null : get_post($id); + if ( + ! $item instanceof WP_Post + || $item->post_type !== DiviLibraryStore::POST_TYPE + || $item->post_status !== 'publish' + ) { + return Errors::notFound( + sprintf('Bibliothekseintrag %s gibt es nicht (oder er ist nicht aktiv).', (string) ($id ?? '?')), + 'Einträge mit lit/library-list auflisten.' + ); + } + + return current_user_can('read_post', (int) $item->ID) + ? $item + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu lesen.', (int) $item->ID)); + } + + /** @return list|WP_Error The blocks to insert. */ + private function blocks(WP_Post $item, string $mode, bool $global): array|WP_Error + { + $id = (int) $item->ID; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + return Errors::unsupported( + sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id), + 'Den Eintrag im Divi-Builder öffnen und speichern.' + ); + } + + if ($roots === []) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id), + ['library_id' => 'leer'] + ); + } + + if ($mode === 'global') { + if (count($roots) !== 1) { + return Errors::invalidInput( + 'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde ' + . 'gespeichert.', + ['mode' => 'mode "copy" verwenden'] + ); + } + + return [GlobalInstance::of($roots[0], $id)]; + } + + return array_map( + static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(), + $roots + ); + } +} +``` + +`LibraryIntegration` – Konstruktor um `private ?Closure $diviWritable = null` (letzter Parameter, Import `Closure`) erweitern und in `abilities()` ergänzen: + +```php + new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable), +``` + +`DiviIntegration::library(?Closure $diviWritable = null)` reicht `$diviWritable` als letzten Parameter an `LibraryIntegration` weiter. `Plugin::boot()`: `$divi->library($diviWritable)`. + +`docs/guides/overview.md` – „Direkt sichtbar:“ um `lit-library-insert` ergänzen (nach `lit-divi-delete`, Reihenfolge wie bisher, der Absatz endet mit einer Leerzeile – der ToolNamingTest liest bis dahin): + +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, +`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, +`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`. +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit-library-insert as copy or global instance in the builder's format (E5)`. + +--- + +### Task 14: `lit/divi-node-copy` – Knoten zwischen Seiten kopieren (E6) + +Kopiert einen gespeicherten Knoten byte-gleich an eine Position eines anderen Posts (Seite oder Bibliothekseintrag). Quelle: Leserechte wie `lit-divi-outline` (auch Bibliothekseinträge) und die `source_tree_rev` (Pfad-IDs gelten nur für ihren Stand); Ziel: wie `lit-divi-insert` (`tree_rev`, StructureRules, alles oder nichts, bei Bibliothekszielen `GlobalEdits`). Globale Instanzen bleiben verknüpft. Die Kopie läuft durch `StoredNodeGuard` des Ziels. Gruppe „Divi“, indirekt. + +**Files:** +- Create: `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriteAbility` (`precheck`, `change`, `commit`), `DiviPageLoader::load()`, `DiviComposition::storedGuard/editor`, `GlobalEdits`, `LibraryFixtures`. +- Produces: `final class DiviNodeCopyAbility extends DiviWriteAbility { __construct(DiviWriter $writer, DiviPageLoader $loader) }` – `lit/divi-node-copy`, indirekt, Eingabe `post_id`, `tree_rev`, `source_post_id`, `source_id`, `source_tree_rev`, Position. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`: + +```php + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + $this->source = LibraryFixtures::page( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + ['ID' => 8, 'post_title' => 'Quelle'] + ); + LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]); + } + + private function ability(): DiviNodeCopyAbility + { + $access = LibraryFixtures::access(); + $loader = LibraryFixtures::loader($access); + $failures = []; + $globals = new GlobalEdits($access, Mockery::mock(GlobalUsages::class)); + + return new DiviNodeCopyAbility( + DiviWriterFixture::writer($this->saved, $failures, false, $loader, $globals), + $loader + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge([ + 'post_id' => 7, + 'tree_rev' => Rev::of((string) $this->target->post_content), + 'source_post_id' => 8, + 'source_tree_rev' => Rev::of((string) $this->source->post_content), + 'source_id' => 's1', + 'after' => 's0', + ], $extra); + } + + public function testCopiesANodeByteForByteBetweenPages(): void + { + $bytes = Serializer::block( + NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find('s1') + ); + + $result = $this->ability()->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertCount(1, $this->saved, 'only the target is written'); + self::assertStringContainsString($bytes, $this->saved[0]); + self::assertSame('s1', $result['changed'][0]['id']); + self::assertFalse($this->ability()->isDirect()); + } + + public function testGlobalInstancesStayLinked(): void + { + $result = $this->ability()->execute($this->input(['source_id' => 'g0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability()->execute($this->input(['source_id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testTheSourceNeedsItsOwnRights(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8) + ); + + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + } + + public function testCopiesIntoALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1055, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.m0', + 'after' => null, + 'parent' => 's0.r0.c0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]); + } +} +``` + +In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-node-copy'` ergänzen. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest'` +Expected: FAIL (`DiviNodeCopyAbility` fehlt) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`: + +```php + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'source_post_id' => ['type' => 'integer', 'minimum' => 1], + 'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'], + 'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** The source needs its own read rights – checked in permission() too. */ + protected function precheck(array $input): ?WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + + return $source instanceof WP_Error ? $source : null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) { + return Errors::revMismatch( + 'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde gespeichert.', + 'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.' + ); + } + + $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : ''; + $node = $source->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $source->post); + } + + $copy = $node->copy(); + $problems = $composition->storedGuard()->problems($copy); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'gespeichert.', + ['source_id' => implode('; ', $problems)] + ); + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, [$copy]); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.', + ['source_id' => $violation] + ); + } + + return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID))) + ->record($copy, 'inserted'); + } +} +``` + +`DiviIntegration::abilities()` – nach `DiviReplaceTextAbility` ergänzen: `new DiviNodeCopyAbility($writer, $loader),`. + +`docs/guides/overview.md`: „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`)“ → „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Pages/DiviNodeCopyAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-node-copy between pages and library entries`. + +--- + +### Task 15: `lit/divi-dynamic-sources` (E12, Antwort F4) + +E14 ordnet das Tool 3a zu. Es liefert Divis Dynamic-Content-Optionen (`DynamicContentOptions::get_options` für einen Beispiel-Eintrag des Typs, sonst 0; über `DynamicContentMap`, ohne `loop_*`, S36) mit dem passenden Platzhalter (`{{post:…}}`, `{{link:}}`) bzw. dem Namen für die Vollform, und die Felder des Typs aus `ContentSchema` (Pods, registriertes und gefundenes Meta). Antwort F4 (verbindlich): nur textartige Felder als Quelle anbieten, andere mit Grund auslassen (Beziehung/Datei zeigt Divi nur als IDs, F26; Pods mit Tabellen-Speicherung liest Divi nicht; geschützte Schlüssel mit `_` nie, S12). Die Feldliste nur mit der Divi-Rolle `read_dynamic_content_custom_fields` (E3, F25). Gruppe „Divi“, indirekt, nur lesend. + +**Files:** +- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`options()`) +- Create: `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DynamicContentMap::fromOptions()`, `DynamicContentRules::DEFAULT_DYNAMIC_CONTENT/isMetaKey()`, `Placeholders::POST`, `ContentSchema::fields()`, `FieldDefinition`, `PostGuard::postType()`, `JsonMap::of()`, `DiviRoles`. +- Produces: `DynamicContentMap::options(int $postId): ?array}>`; `final class DiviDynamicSourcesAbility implements Ability { __construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null) }` – `lit/divi-dynamic-sources`. + +- [ ] **Step 1: Failing tests schreiben** + +In `DynamicContentMapTest`: + +```php + public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']); + self::assertSame('post_meta_key', $options['post_meta_key']['label']); + self::assertSame('Standard', $options['post_meta_key']['group']); + self::assertArrayNotHasKey('loop_post_title', $options); + self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831)); + } +``` + +`tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`: + +```php +justReturn( + (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] + ); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('fields')->with('page')->andReturn([ + 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), + 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), + 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), + 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), + '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), + 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), + ]); + + return $schema; + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], ?string $storage = 'meta', ?array $registry = null): DiviDynamicSourcesAbility + { + $registry ??= DiviFixtures::dynamicContentRegistry(); + + return new DiviDynamicSourcesAbility( + $this->schema(), + new DynamicContentMap(static fn (int $id): array => $registry), + LibraryFixtures::roles($deniedRoles), + static fn (string $postType): int => 831, + static fn (string $postType): ?string => $storage + ); + } + + public function testListsDiviSourcesWithTheirPlaceholders(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + $sources = array_column($result['sources'], null, 'name'); + self::assertSame(831, $result['sample_post_id']); + self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['site_title']['placeholder']); + self::assertArrayNotHasKey('loop_post_title', $sources); + self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); + self::assertFalse($this->ability()->isDirect()); + self::assertTrue($this->ability()->annotations()['readonly']); + } + + public function testOffersTextLikeFieldsAndNamesTheOthers(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); + self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); + $omitted = array_column($result['omitted'], 'reason', 'key'); + self::assertSame(['foto', 'projekte', '_intern'], array_keys($omitted)); + self::assertStringContainsString('nur IDs', $omitted['foto']); + self::assertStringContainsString('„_“', $omitted['_intern']); + } + + public function testPodsTableStorageIsNotReadableByDivi(): void + { + $result = $this->ability([], 'table')->execute(['post_type' => 'page']); + + self::assertSame(['telefon'], array_column($result['fields'], 'key')); + self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); + } + + public function testFieldListNeedsTheDiviRole(): void + { + $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); + + self::assertSame([], $result['fields']); + self::assertSame([], $result['omitted']); + self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); + } + + public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void + { + $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); + + self::assertContains('post_title', array_column($result['sources'], 'name')); + self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); + } + + public function testUnknownPostTypeIsNotFound(): void + { + Functions\when('get_post_type_object')->justReturn(null); + + self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); + } +} +``` + +In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-dynamic-sources'` ergänzen. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest'` +Expected: FAIL + +- [ ] **Step 3: Implementieren** + +`DynamicContentMap` – `forPost()` auf eine gemeinsame Rohabfrage umstellen und `options()` ergänzen: + +```php + /** @return array>|null Null = use DynamicContentRules' default map. */ + public function forPost(int $postId): ?array + { + $raw = $this->raw($postId); + + return $raw === null ? null : self::fromOptions($raw); + } + + /** + * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same + * names and types as forPost(), loop_* left out). Null when Divi's registry is unavailable. + * + * @return array}>|null + */ + public function options(int $postId): ?array + { + $raw = $this->raw($postId); + if ($raw === null) { + return null; + } + + $options = []; + foreach (self::fromOptions($raw) as $name => $settings) { + $option = is_array($raw[$name] ?? null) ? $raw[$name] : []; + $options[$name] = [ + 'label' => is_string($option['label'] ?? null) ? $option['label'] : $name, + 'group' => is_string($option['group'] ?? null) ? $option['group'] : '', + 'settings' => $settings, + ]; + } + + return $options; + } + + /** @return array|null */ + private function raw(int $postId): ?array + { + try { + $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); + } catch (Throwable $e) { + Log::failure(sprintf('divi dynamic content options of post %d', $postId), $e->getMessage()); + + return null; + } + + return is_array($options) && $options !== [] ? $options : null; + } +``` + +`src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`: + +```php +}} (answer + * F4: text-like fields only; relation/file fields show IDs in Divi (F26), Pods table storage is not + * post meta, protected keys never (S12) – each left out with its reason). The field list needs the + * Divi role option read_dynamic_content_custom_fields (E3, F25). + */ +final class DiviDynamicSourcesAbility implements Ability +{ + /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ + private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice', 'unknown']; + + private const ID_TYPES = ['relation', 'file']; + + /** @var Closure(string): int */ + private Closure $samplePost; + + /** @var Closure(string): ?string */ + private Closure $podStorage; + + /** + * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). + * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null without a pod. + */ + public function __construct( + private ContentSchema $schema, + private DynamicContentMap $map, + private DiviRoles $roles, + ?Closure $samplePost = null, + ?Closure $podStorage = null + ) { + $this->samplePost = $samplePost ?? static function (string $postType): int { + $ids = get_posts([ + 'post_type' => $postType, + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => 1, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + return is_array($ids) && isset($ids[0]) ? (int) $ids[0] : 0; + }; + $this->podStorage = $podStorage ?? static function (string $postType): ?string { + if (! function_exists('pods_api')) { + return null; + } + try { + $pod = pods_api()->load_pod(['name' => $postType]); + } catch (Throwable) { + return null; + } + + return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; + }; + } + + public function name(): string + { + return 'lit/divi-dynamic-sources'; + } + + public function label(): string + { + return 'Dynamic-Content-Quellen'; + } + + public function description(): string + { + return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' + . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' + . 'Datei- und Tabellenfelder stehen mit Grund unter omitted.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_type' => ['type' => 'string'], + 'sample_post_id' => ['type' => 'integer'], + 'sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], + ], + ], + 'fields' => ['type' => 'array', 'items' => ['type' => 'object']], + 'omitted' => ['type' => 'array', 'items' => ['type' => 'object']], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $sample = ($this->samplePost)($postType); + $notes = [ + 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' + . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', + ]; + $options = $this->map->options($sample); + if ($options === null) { + $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; + $options = []; + foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { + $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; + } + } + + $sources = []; + foreach ($options as $name => $option) { + $sources[] = [ + 'name' => (string) $name, + 'label' => $option['label'], + 'group' => $option['group'], + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($option['settings']), + ]; + } + + $fields = []; + $omitted = []; + if ($this->roles->allows('read_dynamic_content_custom_fields')) { + [$fields, $omitted] = $this->fields($postType); + } else { + $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' + . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; + } + + return [ + 'post_type' => $postType, + 'sample_post_id' => $sample, + 'sources' => $sources, + 'fields' => $fields, + 'omitted' => $omitted, + 'notes' => $notes, + ]; + } + + private static function placeholder(string $name): ?string + { + $post = array_search($name, Placeholders::POST, true); + if ($post !== false) { + return '{{post:' . $post . '}}'; + } + + return str_starts_with($name, 'post_link_url_') ? '{{link:}}' : null; + } + + /** + * @return array{0: list>, 1: list>} Offered and omitted fields. + */ + private function fields(string $postType): array + { + $storage = ($this->podStorage)($postType); + $fields = []; + $omitted = []; + foreach ($this->schema->fields($postType) as $field) { + if ($field->status === FieldDefinition::HIDDEN) { + continue; + } + + $entry = ['key' => $field->key, 'label' => $field->label]; + $reason = match (true) { + ! DynamicContentRules::isMetaKey($field->key) => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', + $field->source === 'pods' && $storage === 'table' => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', + in_array($field->type, self::ID_TYPES, true) => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', + ! in_array($field->type, self::TEXT_TYPES, true) => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), + default => null, + }; + if ($reason !== null) { + $omitted[] = $entry + ['reason' => $reason]; + continue; + } + + $fields[] = $entry + [ + 'type' => $field->type, + 'source' => $field->source, + 'placeholder' => '{{field:' . $field->key . '}}', + ]; + } + + return [$fields, $omitted]; + } +} +``` + +`DiviIntegration::abilities()` – nach `DiviNodeCopyAbility` (Import `DynamicContentMap`, `DiviDynamicSourcesAbility`): + +```php + new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), +``` + +`docs/guides/overview.md`: „…, `lit/divi-node-copy`)“ → „…, `lit/divi-node-copy`, `lit/divi-dynamic-sources`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-dynamic-sources with text-like fields only (E12, F4)`. + +--- + +### Task 16: Guides, Server-Instructions, Readme, Version 0.4.0, Spec- und Entscheidungs-Nachträge + +Dokumentation zu allem aus 3a, deutsch mit Umlauten, neutral formuliert (keine Chatbot-Marke; der Readme-Satz „z. B. Claude oder ein anderer MCP-fähiger Client“ wird dabei neutralisiert). Neues Guide-Thema `divi-library` mit dem Ablauf „Globale Module vorschlagen“ (E14; MCP-Prompts gibt es im Plugin noch nicht, siehe offene Punkte). Server-Instructions bekommen eine Zeile zur Bibliothek, wenn die Gruppe aktiv ist. + +**Files:** +- Create: `docs/guides/divi-library.md` +- Modify: `docs/guides/overview.md`, `docs/guides/divi-placeholders.md`, `docs/guides/divi-structure.md`, `docs/guides/divi-design-system.md`, `docs/guides/workflows.md` +- Modify: `src/Guides/InstructionsBuilder.php` +- Modify: `readme.txt`, `wp-lit-mcp.php`, `src/Plugin.php` +- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`, `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` +- Test: `tests/Unit/Guides/InstructionsBuilderTest.php`, `tests/Unit/Guides/ToolNamingTest.php` (unverändert, muss grün bleiben) + +**Interfaces:** +- Consumes: alle Tools aus Task 2–15. +- Produces: Guide-Thema `divi-library`; `Plugin::VERSION = '0.4.0'`; Instructions-Zeile „Divi-Bibliothek: …“. + +- [ ] **Step 1: Failing test schreiben** + +In `InstructionsBuilderTest`: + +```php + public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void + { + $library = Mockery::mock(Integration::class); + $library->allows('id')->andReturn('divi-library'); + $library->allows('isAvailable')->andReturn(true); + $library->allows('abilities')->andReturn([$this->ability('lit/library-insert', true)]); + $core = $this->coreWith($this->ability('lit/guide', true)); + + $with = (new InstructionsBuilder(new IntegrationRegistry($core, $library)))->build(); + $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('Divi-Bibliothek: globale Module', $with); + self::assertStringContainsString('confirm: true', $with); + self::assertStringNotContainsString('Divi-Bibliothek:', $without); + self::assertLessThan(2000, strlen($with)); + } +``` + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter InstructionsBuilderTest` +Expected: FAIL + +- [ ] **Step 3: Guide `divi-library` anlegen** + +`docs/guides/divi-library.md`: + +```markdown +# Divi-Bibliothek und globale Module + +Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung. + +- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig. +- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des + Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf + veröffentlichten. + +## Werkzeuge + +- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` + (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search`, `category`, weiterblättern + mit `cursor`. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen + Einträgen die Seiten, die ihn zeigen (`affected`, `affected_count`). +- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen + Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. + Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` + der Zielseite ist Pflicht. +- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`title`, + `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der Knoten auf der Seite bleibt eine + eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz ersetzen (siehe Ablauf unten). +- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren + (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle; Ziel mit `post_id`, + `tree_rev`, Position). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. +- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, + `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete` und `lit/divi-duplicate-node` + mit der ID des Eintrags als `post_id`. + +## Globale Module erkennen und ändern + +1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058] + (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite + lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. +2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. +3. Ohne `confirm` ist das ein Probelauf: Antwort `confirm_required` mit `affected` (betroffene Seiten, Titel + nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; gespeichert wird nichts. +4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit + `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). +5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine + Sicherungskopie (`backup_id`). Divis CSS-Cache wird für alle Seiten geleert. + +Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses +Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; ein globaler +Eintrag enthält keine globalen Module. + +`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt +sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen. + +## Rechte + +- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen + Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge + sind immer aktiv). +- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“, + „Globale Bibliothekselemente bearbeiten“. Fehlt eines, antwortet das Werkzeug mit `forbidden` und diesem + Hinweis. +- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf + Bibliothekseinträge, auch über die Divi-Werkzeuge; `lit-library-insert` ist außerdem gesperrt, wenn + `lit-divi-insert` gesperrt ist. +- Ohne das Recht `unfiltered_html` lassen sich Knoten mit eigenem CSS, eigenen HTML-Attributen, dem + Code-Modul oder dynamischem Inhalt mit HTML davor/danach nicht in einen anderen Eintrag übertragen + (Kopieren, Einfügen, Speichern) – „nur im Divi-Builder“. + +## Ablauf: globale Module vorschlagen + +1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder + Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action). +2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt + danach auf allen Seiten. +3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern. +4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt + einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen. +5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen. + +Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt. +``` + +- [ ] **Step 4: Übrige Guides anpassen** + +`docs/guides/overview.md`: +- In „Weitere Themen:“ `divi-library` ergänzen: + +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`. +``` + +- Direkt darunter einen eigenen Absatz (nach einer Leerzeile, damit der Themen-Test ihn nicht mitliest): + +```markdown +Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf. +``` + +- Nach „Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.“ ergänzen: „Sie arbeiten + auch auf Einträgen der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`.“ + +`docs/guides/divi-placeholders.md` – Tabellenzeile nach `{{field:}}` einfügen: + +```markdown +| `{{link:}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden | +``` + +Den Absatz „In Link-Feldern (`url`, `button_url` …) …“ ersetzen durch: + +```markdown +In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:}}` verwenden statt fester +Adressen wie `/?page_id=…`. Der Eintrag muss existieren, lesbar sein und darf nicht im Papierkorb liegen; +`{{link:…}}` außerhalb von Link-Feldern lehnt das Werkzeug ab. Sonst nimmt ein Link-Feld jeden dynamischen +Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine +Link-Variable). + +Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources` +(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als +`{{field:…}}` eignen (nur textartige; Beziehungs- und Dateifelder zeigt Divi nur als IDs). +``` + +`docs/guides/divi-structure.md` – den ersten Punkt unter „Was nicht bearbeitet wird“ ersetzen durch: + +```markdown +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, + duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global ]`: ihren Inhalt im + Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe + `lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in + `skipped_nodes`. +``` + +`docs/guides/divi-design-system.md` – vor „Regeln:“ einfügen: + +```markdown +Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile, +`module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens 200 Presets je Antwort. +``` + +`docs/guides/workflows.md` – nach „Divi-Seite bauen“ einfügen: + +```markdown +## Bibliothek und globale Module + +Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert` +einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`. +Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen: +`lit-guide` topic `divi-library`. +``` + +Im Abschnitt „Text auf vielen Seiten ersetzen“ den letzten Satz ersetzen durch: „Globale Module werden nicht +geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic +`divi-library`).“ + +- [ ] **Step 5: Server-Instructions** + +`InstructionsBuilder::build()` – nach dem `$divi`-Block: + +```php + $library = str_contains(', ' . $active . ',', ', divi-library,') + ? [ + '- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global ]) im Bibliothekseintrag ändern; ' + . 'das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true (lit-guide topic ' + . '"divi-library").', + ] + : []; +``` + +und im `implode` `array_merge([...], $divi, $library)`. + +- [ ] **Step 6: Readme und Version** + +`wp-lit-mcp.php`: `* Version: 0.4.0`. `src/Plugin.php`: `public const VERSION = '0.4.0';`. `readme.txt`: `Stable tag: 0.4.0`. + +`readme.txt` – im ersten Satz der Beschreibung „(z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth)“ durch „(ein MCP-fähiger Client mit OAuth)“ ersetzen und die Aufzählung um „Divi-Seiten und die Divi-Bibliothek (auch globale Module)“ ergänzen. + +Unter „Auswirkungen auf die gesamte Website“ anfügen: + +```text +* Divi-Bibliothek: LIT MCP legt Bibliothekseinträge selbst an (Post-Typ et_pb_layout, Status publish, mit + Divis Begriffen scope, layout_type, module_width und dessen Meta-Feldern) und benutzt keine Builder-Routen. + Eine Änderung an einem globalen Bibliothekseintrag wirkt sofort auf allen Seiten, die ihn zeigen; LIT MCP + verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei + abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten + und den Theme-Builder-Cache. +* Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek + speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab. +* Interne Links über {{link:}} nutzen Divis eigenen dynamischen Inhalt „ Link“: die Adresse wird beim + Anzeigen ermittelt. +``` + +Unter „Rückgängig machen“ nach dem Punkt „Divi-Seiten“ einfügen: + +```text +* Bibliothekseinträge: jede Änderung legt vorher eine Revision an (revision_id), bei abgeschalteten Revisionen + eine Sicherungskopie im Papierkorb (backup_id); zurück über WordPress (Eintrag der Divi-Bibliothek + bearbeiten → Revisionen). Neu angelegte Einträge (lit/library-save) in der Divi-Bibliothek löschen. +``` + +Unter „Frequently Asked Questions“ anfügen: + +```text += Was passiert, wenn der Assistent ein globales Modul ändert? = + +Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst nach einem Probelauf, der die +betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des +Bibliothekseintrags. +``` + +Changelog oben: + +```text += 0.4.0 = +* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag + speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt). +* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf + mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung. +* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy). +* Interne Links mit {{link:}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder. +* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und + lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module + hin; lit-site-status prüft die genutzten Divi-Funktionen. +* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek. +``` + +- [ ] **Step 7: Spec und Entscheidungen nachtragen** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`: + +- §4.3, zweiter Punkt, anfügen: „**Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“.“ +- §5.2, letzter Punkt: „(Bearbeitung ab Etappe 3)“ → „(bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2)“. +- §5.4, Platzhalter-Tabelle nach `{{field:}}`: „| `{{link:}}` | interner Link: Dynamic Content `post_link_url_` mit `post_id` (nur Link-Felder; Etappe 3a, E13a) |“ +- §5.7: Zeile `lit/divi-dynamic-sources` → „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeile `lit/library-list`, `lit/library-get` → „| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9) |“; Zeile `lit/library-insert` → „… als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt“; Zeile `lit/divi-node-copy` → „Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, S7/S26-Prüfung E6)“. +- §9.2, Themenliste: „`divi-library`“ und „`media`“ ergänzen sowie „Thema `all` liefert alle Themen (Etappe 3a)“. +- §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)“ + +In `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` am Ende anfügen: + +```markdown +## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md) + +- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` = + vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`, + ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen + `page--globale-instanzen.html`. +- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload); + `LibraryContent::load()` vereinheitlicht das vor jeder Übernahme. +- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`); + entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10). +- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch `$variable`-Verweise + (Ablehnung, wenn `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund: + R2-Befund 1 (before/after nach dem Autor des Ziel-Posts). +- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`). +- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe + „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf + Bibliothekseinträge (`ExposurePolicy::groupWritable`). +- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte + Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die + betroffenen Seiten. +- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5). +- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status`. +- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt. +``` + +- [ ] **Step 8: Tests und Lint** + +Run: `vendor/bin/phpunit --filter 'InstructionsBuilderTest|ToolNamingTest|GuideRepositoryTest|GuideAbilityTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +Run: `grep -rn "Claude\|ChatGPT\|claude.ai" docs/guides readme.txt src/Admin src/Guides` +Expected: keine Treffer + +- [ ] **Step 9: Commit** + +```bash +git add docs/guides src/Guides/InstructionsBuilder.php readme.txt wp-lit-mcp.php src/Plugin.php docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md tests/Unit/Guides/InstructionsBuilderTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `docs: guides, readme and spec for the Divi Library (v0.4.0)`. + +--- + +### Task 17: Ende-zu-Ende-Test `divi-library-smoke.sh` und Abnahme-Notiz + +Prüft 3a über den echten OAuth-Endpunkt in DDEV (Divi 5.2.1): Exposition, Speichern normal/global mit Divis Termen, Einfügen als Instanz und Kopie, globaler Probelauf/`confirm`, Knoten kopieren, `{{link:ID}}`, die Übernahmen, ein Redakteur ohne `unfiltered_html` und die Gruppe „nur lesen“. Räumt alles wieder auf. Danach die manuelle Abnahme-Notiz für die Punkte, die den Visual Builder brauchen. + +**Files:** +- Create: `tests/Integration/divi-library-smoke.sh` +- Create: `docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md` + +**Interfaces:** +- Consumes: `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `rpc`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`), Fixture `tests/Fixtures/divi/converted-landing.html`. +- Produces: Skript, das mit `ALL CHECKS PASSED` endet. + +- [ ] **Step 1: Skript schreiben** + +`tests/Integration/divi-library-smoke.sh`: + +```bash +#!/usr/bin/env bash +# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy) through the OAuth-protected +# endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh +# Creates draft pages and library entries (titles "LIT Smoke …"), a temporary role/user without +# unfiltered_html, and removes everything again (posts with their revisions, backups, change-log rows, +# role, user, app passwords, options). +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) +EDITOR_LOGIN=lit-lib-editor +FIXTURES=$PLUGIN/tests/Fixtures/divi +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') +ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__') +CREATED_POSTS=() + +restore_exposure() { + if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then + wp option delete lit_mcp_exposure >/dev/null 2>&1 || true + else + wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true + fi +} + +cleanup() { + # Library entries and backups the tools created ("LIT Smoke …", also from a run that stopped early). + for id in $(wp post list --post_type=et_pb_layout --post_status=publish,draft,trash --s="LIT Smoke" --field=ID 2>/dev/null); do + CREATED_POSTS+=("$id") + done + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" + cleanup_app_passwords_for "$EDITOR_LOGIN" + LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval ' +global $wpdb; +$table = $wpdb->prefix . "lit_mcp_log"; +$u = get_user_by("login", getenv("LIT_LOGIN")); +if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); } +foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) { + $wpdb->delete($table, ["object_id" => $id]); +}' >/dev/null 2>&1 || true + wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true + wp role delete lit_lib_editor >/dev/null 2>&1 || true + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true + restore_exposure +} +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +create_page() { # $1 = markup (not a file), $2 = title -> prints the ID + LIT_CONTENT="$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +content_of() { # $1 = post ID + wp post get "$1" --field=post_content +} + +outline() { # $1 = post ID -> the lit-divi-outline result + tool lit-divi-outline "{\"post_id\":$1}" +} + +# 0. Two pages from the converted landing page fixture +LANDING=$(cat "$FIXTURES/converted-landing.html") +PAGE_ID=$(create_page "$LANDING" "LIT Smoke Quelle"); CREATED_POSTS+=("$PAGE_ID") +TARGET_ID=$(create_page "$LANDING" "LIT Smoke Ziel"); CREATED_POSTS+=("$TARGET_ID") +connect "$ADMIN_LOGIN" + +# 1. Exposure: lit-library-insert direct, the other new tools indirect +LIST=$(rpc tools/list '{}') +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "lit-library-insert is not direct" +for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do + if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi +done +pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect" + +# 2. Save a section as normal library entry: Divi's terms, no Divi flags, unchanged node bytes +SOURCE=$(outline "$PAGE_ID") +REV=$(echo "$SOURCE" | jq -r '.tree_rev') +SECTION=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "section")][1].id') +SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg s "$SECTION" \ + '{post_id: $id, tree_rev: $rev, id: $s, title: "LIT Smoke Abschnitt"}')") +LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty') +[ -n "$LIB_ID" ] || fail "library-save: $SAVED" +CREATED_POSTS+=("$LIB_ID") +[ "$(wp post term list "$LIB_ID" layout_type --field=slug)" = "section" ] || fail "layout_type of $LIB_ID" +[ "$(wp post term list "$LIB_ID" scope --field=slug)" = "non_global" ] || fail "scope of $LIB_ID" +[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags" +[ "$(LIT_PAGE="$PAGE_ID" LIT_LIB="$LIB_ID" wp eval ' +$page = (string) get_post((int) getenv("LIT_PAGE"))->post_content; +$lib = (string) get_post((int) getenv("LIT_LIB"))->post_content; +$open = ""; $close = ""; +$inner = str_starts_with($lib, $open) && str_ends_with($lib, $close) ? substr($lib, strlen($open), -strlen($close)) : ""; +echo $inner !== "" && str_contains($page, $inner) ? "yes" : "no";')" = "yes" ] || fail "library entry does not hold the node bytes" +ability lit/library-list '{"search":"LIT Smoke"}' | jq -e --argjson id "$LIB_ID" '.items | map(.id) | index($id) != null' >/dev/null \ + || fail "library-list does not show $LIB_ID" +pass "lit/library-save: normal section with Divi's terms, node bytes in a divi/placeholder frame, listed" + +# 3. Save a module as global entry, insert it as instance into the target page +TEXT=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "text")][0].id') +GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg m "$TEXT" \ + '{post_id: $id, tree_rev: $rev, id: $m, title: "LIT Smoke Globales Modul", scope: "global"}')") +GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty') +[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED" +CREATED_POSTS+=("$GLOB_ID") +[ "$(wp post term list "$GLOB_ID" scope --field=slug)" = "global" ] || fail "scope of $GLOB_ID" +[ "$(wp post meta get "$GLOB_ID" _et_pb_excluded_global_options)" = "[]" ] || fail "excluded global options of $GLOB_ID" +TARGET=$(outline "$TARGET_ID") +COLUMN=$(echo "$TARGET" | jq -r '[.nodes[] | select(.type == "column")][0].id') +INSERTED=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$TARGET" | jq -r .tree_rev)" \ + --argjson lib "$GLOB_ID" --arg c "$COLUMN" '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: $c}')") +echo "$INSERTED" | jq -e '.ok == true' >/dev/null || fail "library-insert global: $INSERTED" +content_of "$TARGET_ID" | grep -q "' "LIT Smoke CSS") +CREATED_POSTS+=("$CSS_ID") +wp role create lit_lib_editor "LIT Smoke Bibliothek" --clone=editor >/dev/null +wp cap remove lit_lib_editor unfiltered_html >/dev/null +wp option update lit_mcp_allowed_roles '["administrator","lit_lib_editor"]' --format=json >/dev/null +wp user create "$EDITOR_LOGIN" lit-lib-editor@example.invalid --role=lit_lib_editor >/dev/null +connect "$EDITOR_LOGIN" +BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum) +CARRY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(outline "$TARGET_ID" | jq -r .tree_rev)" \ + --argjson src "$CSS_ID" --arg srev "$(outline "$CSS_ID" | jq -r .tree_rev)" \ + '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')") +[ "$(error_code "$CARRY")" = "validation_failed" ] || fail "editor carried custom CSS: $CARRY" +KEEP=$(ability lit/library-save "$(jq -cn --argjson id "$CSS_ID" --arg rev "$(outline "$CSS_ID" | jq -r .tree_rev)" \ + '{post_id: $id, tree_rev: $rev, id: "s0", title: "LIT Smoke CSS-Eintrag"}')") +[ "$(error_code "$KEEP")" = "validation_failed" ] || fail "editor saved custom CSS to the library: $KEEP" +[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "a refused copy wrote something" +pass "user without unfiltered_html: custom CSS neither copied nor saved to the library" + +# 10. Group "Divi-Bibliothek" read-only: no writes to library entries, lit-library-insert gone +wp option update lit_mcp_exposure '{"@readonly:divi-library":true}' --format=json >/dev/null +connect "$ADMIN_LOGIN" +if rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then + fail "lit-library-insert listed although the group is read-only" +fi +LREV=$(outline "$LIB_ID" | jq -r '.tree_rev') +[ "$LREV" != "null" ] || fail "library entry no longer readable" +BLOCKED=$(tool lit-divi-update "$(jq -cn --argjson id "$LIB_ID" --arg rev "$LREV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0", label: "LIT Smoke gesperrt"}]}')") +[ "$(error_code "$BLOCKED")" = "forbidden" ] || fail "write to a library entry with the group read-only: $BLOCKED" +restore_exposure +pass "group Divi-Bibliothek read-only: entries readable, not writable, lit-library-insert not listed" + +finish +``` + +- [ ] **Step 2: Skript ausführen** + +Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh` +Expected: alle Zeilen `ok - …`, am Ende `ALL CHECKS PASSED`. Danach prüfen, dass nichts übrig ist: + +Run: `cd /home/janniclas/Projekte/WordPress && ddev wp post list --post_type=et_pb_layout,page --post_status=publish,draft,trash --s="LIT Smoke" --format=count` +Expected: `0` + +Die übrigen Smokes laufen unverändert grün: + +Run: `cd /home/janniclas/Projekte/WordPress && for s in divi-write-smoke divi-read-smoke mcp-smoke wp-basis-smoke media-admin-smoke; do timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -1; done` +Expected: fünfmal `ALL CHECKS PASSED` + +- [ ] **Step 3: Abnahme-Notiz schreiben** + +`docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md`: + +```markdown +# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek) + +Automatisch geprüft sind bereits: + +- alle Unit-Tests (`composer test`) und Lint (`composer lint`); +- `divi-library-smoke.sh` (neu) und die fünf bisherigen Integrationsskripte (alle in `tests/Integration/`). + +`divi-library-smoke.sh` prüft über den OAuth-Endpunkt: `lit-library-insert` direkt, die übrigen neuen Tools +indirekt; Speichern als normaler und globaler Eintrag mit Divis Begriffen, ohne Divi-Kennzeichen und mit den +unveränderten Bytes des Knotens; Einfügen als Instanz im Format des Visual Builders und als Kopie; Probelauf, +`confirm` und Revision beim Ändern eines globalen Eintrags; Knoten kopieren (auch Ablehnung einer veralteten +Quelle); `{{link:ID}}`; die Übernahmen aus Etappe 2; einen Redakteur ohne `unfiltered_html`, der eigenes CSS +weder kopieren noch in die Bibliothek speichern kann; die Gruppe „Divi-Bibliothek“ auf „nur lesen“. + +Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen +als kurze Notiz zurückmelden; sie werden Folgeaufgaben. + +## 1. Vom Plugin angelegte Bibliothekseinträge + +1. Über den KI-Assistenten einen Abschnitt einer Entwurfsseite als normalen Eintrag und ein Modul als + globalen Eintrag speichern lassen. +2. Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit richtigem Typ (Abschnitt/Modul), der globale + als global. +3. Im Visual Builder einer Seite „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen sich + einfügen. + +## 2. Vom Plugin eingefügte globale Instanz + +1. Den globalen Eintrag mit `lit-library-insert` in eine Entwurfsseite einfügen lassen. +2. Seite im Visual Builder öffnen: das Modul ist als global markiert und zeigt den Inhalt der Bibliothek. +3. Das Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und auf einer + zweiten Seite mit derselben Instanz. +4. Vorschau der Seite: der Inhalt des globalen Moduls wird angezeigt. + +## 3. Globales Modul über den Assistenten ändern + +1. Den Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst die betroffenen Seiten nennen + und nachfragen. +2. Nach Zustimmung: Vorschau beider Seiten zeigt den neuen Text; unter Einstellungen → LIT MCP → Protokoll + steht der Eintrag mit Revision; die Revision ist in WordPress beim Bibliothekseintrag sichtbar. + +## 4. Kopie und Knoten kopieren + +1. Einen normalen Eintrag als Kopie einfügen und einen Abschnitt mit `lit/divi-node-copy` auf eine andere Seite + kopieren lassen. +2. Beide im Builder öffnen, ändern, speichern – nichts Ungewöhnliches (keine verknüpften Änderungen an anderen + Seiten). + +## 5. Interner Link + +1. Einen Button mit `{{link:}}` einfügen lassen. +2. Im Builder zeigt das Link-Feld dynamischen Inhalt „Seite Link“ mit der gewählten Seite. +3. Die Zielseite veröffentlichen: der Button verlinkt auf die schöne Adresse. + +## 6. Rechte und Einstellungen + +1. In Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente bearbeiten“ abschalten und als + Redakteur ein globales Modul ändern lassen: Ablehnung mit Hinweis auf den Rollen-Editor. +2. Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ ist sichtbar; „nur lesen“ entfernt + `lit-library-insert` aus der Werkzeugliste (nach neuer Verbindung). +3. `lit-site-status`: bei Divi keine Einträge unter `problems`. + +## Aufräumen + +Die Testeinträge in der Divi-Bibliothek und die Entwurfsseiten löschen (Titel wie bei der Abnahme vergeben). +Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an. +``` + +- [ ] **Step 4: Commit** + +```bash +chmod +x tests/Integration/divi-library-smoke.sh +git add tests/Integration/divi-library-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md +``` +Commit wie in den Global Constraints mit der Nachricht `test: end-to-end smoke and acceptance note for the Divi Library (3a)`. + +--- + +## Offene Punkte (nicht Teil dieses Plans) + +- `lit/revision-restore` erreicht Bibliothekseinträge nicht (`ContentSchema::EXCLUDED`); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b. +- `lit/divi-replace-text` durchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter als `skipped_nodes` gemeldet. +- MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`. +- Ob der Visual Builder Instanzen mit tief verschachtelten `localAttrs` anders sortiert als nur auf oberster Ebene, zeigt erst ein reicheres Builder-Fixture; die Abnahme (Abschnitt 2) prüft das Verhalten im Builder. +- Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz (`localAttrsMap`) bleibt Builder-Sache (E2). +- `lit/library-make-global`, `lit/library-find-similar`, Theme Builder: Etappe 3b. diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index fe3b4a8..5647575 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -12,12 +12,12 @@ Die Pflege von Kunden-Websites (WordPress + Divi 5 + Pods) kostet viel Handarbei ### 1.2 Ziel -Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem Claude (claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.** +Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem ein KI-Assistent (z. B. über den claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.** ### 1.3 Erfolgskriterien 1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen. -2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe). +2. Vom KI-Assistenten erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe). 3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt) – aber nur, wenn die Seite noch nicht in Divis `serialize_blocks()`-Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff); über Builder oder LIT MCP gespeicherte Seiten bleiben unverändert (E2E Abschnitt 8a); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8). 4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen. 5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot). @@ -114,7 +114,7 @@ Ob dieser Aufbau stabil funktioniert, entscheidet Etappe 0 (§12). - **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set: `lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`. -- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). +- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). **Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“ (`lit/library-*`); `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“. - **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). - In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind. - Ability-Namen: `lit/-`. Der Adapter bildet daraus Tool-Namen (`lit--`). @@ -174,13 +174,13 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ - **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu, was nur Bytes ändert, die noch nicht in Divis `serialize_blocks()`-Form vorlagen; gemeldet als `CHANGED_ON_SAVE` (Ruling S7). - Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar). - Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). -- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert. +- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2). Unbalanciertes Markup wird abgelehnt, nie repariert. ### 5.3 Adressierung und optimistische Sperre - `lit/divi-outline` liefert den Baum mit Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. - Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16). -- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. +- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; der Client liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. - Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent. ### 5.4 Modulbeschreibung @@ -216,6 +216,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ | `{{font:heading}}`, `{{font:body}}` | Theme-Schriften (`--et_global_heading_font` / `--et_global_body_font`) | | `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) | | `{{field:}}` | Dynamic Content `custom_meta_` (Pods/Meta) | +| `{{link:}}` | interner Link: Dynamic Content `post_link_url_` mit `post_id` (nur Link-Felder; Ziel muss für den Benutzer lesbar sein, nicht im Papierkorb, kein automatischer Entwurf, kein Anhang; Entwürfe/private Ziele rendert Divi als `?page_id=`; Etappe 3a, E13a) | | `"preset": ""` | `modulePreset: [""]` | Unbekannte Namen → Fehler mit Liste der vorhandenen. @@ -249,7 +250,7 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich | `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) | | `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul | | `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen | -| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) | +| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4): Beziehungs-, Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter `omitted`, Auswahlfelder liefern den gespeicherten Wert; Beispiel-Post nur einer, den der Benutzer bearbeiten darf | | `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) | | `lit/divi-insert` | schreiben | Knoten (Kurz-/Vollform, auch ganze Abschnitte) an Position `parent`/`before`/`after` einfügen | | `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) | @@ -257,10 +258,10 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich | `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen | | `lit/divi-duplicate-node` | schreiben | Knoten duplizieren | | `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) | -| `lit/library-list`, `lit/library-get` | lesen | Library-Einträge nach Typ (`layout_type`) und Scope | -| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global) | -| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen | -| `lit/divi-node-copy` | schreiben | Knoten von Seite A nach Seite B kopieren | +| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9); nur Einträge mit `edit_post` | +| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global; global braucht Divis „Globale Bibliothekselemente bearbeiten“, P7); Knoten auf der Seite bleibt unverändert; StoredNodeGuard (E6) | +| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt | +| `lit/divi-node-copy` | schreiben | Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, `source_tree_rev` Pflicht, S7/S26-Prüfung E6) | | `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln; optional gleichartige Vorkommen ersetzen | | `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) | | `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts | @@ -353,7 +354,7 @@ Regeln: - Vor jeder Änderung wird der betroffene Bereich (Farben, Variablen, Presets oder Theme-Optionen) als **Snapshot** gesichert (eigene Option, begrenzte Anzahl, Standard 20). Snapshot-ID im `ToolResult` und im Protokoll. - Voraussetzung: `edit_theme_options`; für Variablen zusätzlich Divis Rollen-Recht `variables_manager`. -Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): Claude liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben. +Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): der KI-Assistent liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben. ## 8. Rückmeldung, Fehler, Protokoll, Einstellungen @@ -393,7 +394,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer - **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots. - Umsetzung mit Settings API, ohne JS-Framework. -## 9. Anleitungen für Claude +## 9. Anleitungen für den KI-Assistenten ### 9.1 Server-Instructions @@ -401,15 +402,17 @@ Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zei - aktive Integrationen und Post-Typen, - Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben, - Verweis auf `lit/guide`. +- Etappe 3a: Zeile „Divi-Bibliothek: …“ (globale Module im Bibliothekseintrag ändern, Probelauf und `confirm: true`, Guide `divi-library`), nur wenn die Gruppe „Divi-Bibliothek“ aktiv und beschreibbar ist. ### 9.2 `lit/guide(topic)` Direktes Tool; Anleitungen als Markdown in `docs/guides/`, mit dem Plugin versioniert, per Filter `lit_mcp_guides` erweiterbar. -Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `theme-builder`, `content-types`, `workflows`. +Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `divi-library`, `theme-builder`, `content-types`, `media`, `workflows`. Thema `all` liefert alle Themen in einem Aufruf (Etappe 3a). ### 9.3 MCP-Prompts `corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`. +(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.) ## 10. Sicherheit @@ -462,7 +465,7 @@ Referenz: https://modelcontextprotocol.io/specification |---|---|---| | **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein | | **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen | -| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen | +| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit dem KI-Assistenten bauen und pflegen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | | **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – | diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6e7b6d4..e809d59 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,6 +3,10 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true"> + + + + tests/Unit diff --git a/readme.txt b/readme.txt index d04e238..b239810 100644 --- a/readme.txt +++ b/readme.txt @@ -1,20 +1,20 @@ === WP-LIT-MCP === Contributors: loosenit -Tags: mcp, claude, divi, pods, ai +Tags: mcp, ai, divi, pods, oauth Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.3.0 +Stable tag: 0.4.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html -MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. +MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten. == Description == -WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt: -Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene -WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen +WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (ein MCP-fähiger Client mit OAuth) diese +Website pflegt: Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene +WordPress-Einstellungen, die Mediathek sowie Divi-Seiten und die Divi-Bibliothek (auch globale Module). Jede Änderung läuft mit den Rechten des verbundenen WordPress-Benutzers und wird protokolliert. Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits). @@ -29,7 +29,7 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, * mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln, die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen). -* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im +* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. der Name des Assistenten). Es ist im Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar. * Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die @@ -46,9 +46,11 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post, sanitize_text_field, esc_url_raw); $variable-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. - Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute - von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben - aktiv. + Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS, HTML-Attribute, + Interaktionen und die Lottie-Quelle von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. + Beim Kopieren, Einfügen und Speichern gespeicherter Knoten (Divi-Bibliothek, lit/divi-node-copy) lehnt + LIT MCP für Benutzer ohne unfiltered_html alles ab, was sie nicht selbst schreiben dürften – strenger als + Divi, mit sichtbarer Fehlermeldung. Divis eigene Speicherfilter bleiben aktiv. * Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke ' . Serializer::block($node) . ''; + } + + /** @return list Top-level nodes: the children of the first divi/placeholder, else of the document. */ + public static function roots(Document $document): array + { + foreach ($document->children() as $block) { + if ($block->isPlaceholder()) { + return $block->children(); + } + } + + return $document->children(); + } + + /** Whether $block is or contains a global module. */ + public static function containsGlobal(Block $block): bool + { + if ($block->isGlobal()) { + return true; + } + + foreach ($block->children() as $child) { + if (self::containsGlobal($child)) { + return true; + } + } + + return false; + } + + /** + * Why the roots $after may not replace $before, or null. Instances on pages expect their + * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the + * same type; a layout entry (or one with several roots) holds sections only. A global entry + * contains no global modules (F10: Divi forbids nesting). + * + * @param list $before + * @param list $after + */ + public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string + { + if ($after === []) { + return 'Der Bibliothekseintrag darf nicht leer werden.'; + } + + if ($layoutType === 'layout' || count($before) !== 1) { + foreach ($after as $root) { + if (GlobalInstance::effectiveName($root) !== 'divi/section') { + return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.'; + } + } + } else { + $expected = GlobalInstance::effectiveName($before[0]); + if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) { + return sprintf( + 'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).', + $expected + ); + } + } + + if ($global) { + foreach ($after as $root) { + if (self::containsGlobal($root)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung).'; + } + } + } + + return null; + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index c5d57a8..24da786 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -25,7 +25,10 @@ final class NodeSummary public static function label(Block $block): string { - $label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel'); + $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) + ? $block->attrs()['localAttrs'] + : $block->attrs(); + $label = AttrMap::desktop($attrs, 'module.meta.adminLabel'); if (! is_string($label) || $label === '') { return ''; } @@ -55,6 +58,29 @@ final class NodeSummary return ''; } + /** + * Library entry of a global module: a divi/global-layout instance (its root type in blockName) + * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block. + * + * @return array{id: string, block_name: string}|null + */ + public static function globalRef(Block $block): ?array + { + if (! $block->isGlobal()) { + return null; + } + + $attrs = $block->attrs(); + $id = $attrs['globalModule'] ?? null; + if (! is_string($id) && ! is_int($id)) { + return null; + } + + $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name(); + + return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : '']; + } + /** @return list */ public static function presetIds(Block $block): array { diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index a364371..5d7920a 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -59,7 +59,8 @@ final class Outline /** * @return list, children: int, editable: bool, depth: int, truncated: bool}> + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string}> */ public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { @@ -114,6 +115,7 @@ final class Outline foreach ($rows as $row) { $line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type']; $line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : ''; + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; @@ -127,15 +129,23 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int, truncated: bool} + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string} */ private function row(Block $block, string $id, int $depth): array { $editable = $block->isDivi() && ! $block->isOpaque(); + $global = NodeSummary::globalRef($block); + $type = $block->isDivi() ? $block->shortName() : $block->name(); + if ($global !== null && $global['block_name'] !== '') { + $type = str_starts_with($global['block_name'], 'divi/') + ? substr($global['block_name'], 5) + : $global['block_name']; + } return [ 'id' => $id, - 'type' => $block->isDivi() ? $block->shortName() : $block->name(), + 'type' => $type, 'label' => NodeSummary::label($block), 'excerpt' => NodeSummary::excerpt($block), 'info' => $editable ? NodeSummary::info($block) : '', @@ -147,6 +157,7 @@ final class Outline 'editable' => $editable, 'depth' => $depth, 'truncated' => false, + 'global_id' => $global['id'] ?? null, ]; } } diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 1978adc..613cd6e 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -23,7 +23,8 @@ defined('ABSPATH') || exit; */ final class AttrWriter { - private const STATES = ['value', 'hover', 'sticky']; + /** State keys of a breakpoint entry (StoredNodeGuard uses them too). */ + public const STATES = ['value', 'hover', 'sticky']; /** * Structure attributes (ruling S41), refused for everyone and whatever the catalog says: diff --git a/src/Integrations/Divi/Compose/LinkTargets.php b/src/Integrations/Divi/Compose/LinkTargets.php new file mode 100644 index 0000000..4b6dea2 --- /dev/null +++ b/src/Integrations/Divi/Compose/LinkTargets.php @@ -0,0 +1,16 @@ +}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress. + */ +interface LinkTargets +{ + /** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */ + public function postTypeOf(int $id): ?string; +} diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 34dec92..c7439b4 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -12,10 +12,10 @@ defined('ABSPATH') || exit; /** * Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax: * {{color:primary||gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, - * {{post:title|…}}, {{field:}}. A placeholder must be the whole value (D9); unknown - * names fail with the list of existing ones, labels two entries share fail with their IDs - * (ruling S13a, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef - * wherever it is written. Pure PHP. + * {{post:title|…}}, {{field:}}, {{link:}} (internal link, URL fields only – E13a). + * A placeholder must be the whole value (D9); unknown names fail with the list of existing ones, + * labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor). + * The result is still checked by VariableRef wherever it is written. Pure PHP. */ final class Placeholders { @@ -37,9 +37,10 @@ final class Placeholders 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', ]; - private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; + private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/'; - public function __construct(private DesignSystem $design) + /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */ + public function __construct(private DesignSystem $design, private ?LinkTargets $links = null) { } @@ -54,7 +55,7 @@ final class Placeholders if (str_contains($value, '{{')) { throw new InvalidArgumentException( 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' - . 'post, field).' + . 'post, field, link).' ); } @@ -68,6 +69,7 @@ final class Placeholders 'var' => $this->variable($name), 'font' => $this->font($name), 'post' => $this->post($name), + 'link' => $this->link($name), default => $this->field($name), }; } @@ -152,6 +154,36 @@ final class Placeholders return VariableRef::serialize('content', $option, $settings); } + /** + * {{link:}} → Divi's own internal link post_link_url_ with settings.post_id (F27): + * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published. + * Whether Divi offers that option (public post types only) VariableRef checks against the registry; + * that the value goes into a URL field and the target is readable (S37) the choke point checks again. + * + * @throws InvalidArgumentException + */ + private function link(string $name): string + { + // FILTER_VALIDATE_INT: no id beyond PHP_INT_MAX that would saturate to another entry. + $id = preg_match('/^[1-9][0-9]{0,18}$/D', $name) === 1 ? filter_var($name, FILTER_VALIDATE_INT) : false; + if (! is_int($id)) { + throw new InvalidArgumentException(sprintf( + 'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).', + $name + )); + } + + $type = $this->links?->postTypeOf($id); + if ($type === null) { + throw new InvalidArgumentException(sprintf( + 'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.', + $name + )); + } + + return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]); + } + private function field(string $name): string { if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php new file mode 100644 index 0000000..aa97226 --- /dev/null +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -0,0 +1,324 @@ +, backslash or controls. */ + private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D'; + + private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)'; + + public function __construct( + private ValueCleaner $cleaner, + private VariableRef $references, + private ValueSanitizer $sanitizer + ) { + } + + /** @return list German problems; [] when $root may move to the target post. */ + public function problems(Block $root): array + { + if ($this->sanitizer->allowsUnfilteredHtml()) { + return []; + } + + $problems = []; + $this->walk($root, $problems); + + return array_values(array_unique($problems)); + } + + /** @param list $problems */ + private function walk(Block $block, array &$problems): void + { + if (! $block->isDivi()) { + $problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY); + + return; + } + + $attrs = $this->attributesOf($block); + if ($attrs === null) { + $problems[] = sprintf( + '%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.', + $block->name(), + self::BUILDER_ONLY + ); + + return; + } + + $module = GlobalInstance::effectiveName($block); + if (in_array($module, self::CODE_MODULES, true)) { + $problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY); + } + + foreach (AttrMap::flatten($attrs) as $path => $value) { + $this->value($module, (string) $path, $value, $problems); + } + + foreach ($block->parts() as $part) { + if ($part instanceof Block) { + $this->walk($part, $problems); + } elseif (trim($part) !== '') { + $problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY); + } + } + } + + /** + * The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over + * the library entry, F7); null for an opaque block that cannot be read that way. + * + * @return array|null + */ + private function attributesOf(Block $block): ?array + { + if (! $block->isOpaque()) { + return $block->attrs(); + } + + $attrs = $block->attrs(); + $name = GlobalInstance::effectiveName($block); + if ( + $block->name() !== GlobalInstance::BLOCK + || ! str_starts_with($name, 'divi/') + || $name === GlobalInstance::BLOCK + || self::libraryId($block) === null + ) { + return null; + } + + $local = $attrs['localAttrs'] ?? []; + if ($local instanceof stdClass && get_object_vars($local) === []) { + return []; + } + + return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; + } + + /** + * The entry an instance shows, read like Divi's render (audit I1, hardening): the parsed + * globalModule must be digits naming an ID above 0 ("0123" = 123), and the stored opener must hold + * exactly one "globalModule" – written as plain digits that give the same ID – so the entry Divi + * renders is the one checked here (JSON's last duplicate key would win, Divi's first match). + */ + private static function libraryId(Block $block): ?int + { + $value = $block->attrs()['globalModule'] ?? null; + if (! is_string($value) || preg_match(self::LIBRARY_ID, $value) !== 1 || (int) $value < 1) { + return null; + } + + $raw = $block->rawOpen(); + if ($raw === null) { + return (int) $value; + } + + return substr_count($raw, '"globalModule"') === 1 + && preg_match(self::RENDERED_ID, $raw, $match) === 1 + && (int) $match[1] === (int) $value ? (int) $value : null; + } + + /** + * One flattened path: the builder-only gate, then every string below it – a breakpoint map's + * state values the way AttrWriter hands them to ValueCleaner::clean(). + * + * @param list $problems + */ + private function value(string $module, string $path, mixed $value, array &$problems): void + { + if (! $this->allowedPath($path, $problems)) { + return; + } + + if (! AttrMap::isBreakpointMap($value)) { + $this->node($module, $path, $path, $value, null, null, $problems); + + return; + } + + foreach ($value as $states) { + if ($states instanceof stdClass) { + $states = get_object_vars($states); + } + if (! is_array($states) || array_is_list($states)) { + $this->node($module, $path, $path, $states, null, null, $problems); + continue; + } + foreach ($states as $state => $item) { + $field = in_array($state, AttrWriter::STATES, true) ? null : (string) $state; + $where = $field === null ? $path : $path . '.' . $field; + $this->node($module, $path, $where, $item, $field, $field, $problems); + } + } + } + + /** + * @param string $where $path plus the keys below the state value: builder-only keys at any depth. + * @param string|null $subName First key below the state value (ValueCleaner's sub-kind). + * @param string|null $key Nearest key above the value. + * @param list $problems + */ + private function node( + string $module, + string $path, + string $where, + mixed $value, + ?string $subName, + ?string $key, + array &$problems + ): void { + if ($where !== $path && ! $this->allowedPath($where, $problems)) { + return; + } + + if ($value instanceof stdClass) { + $value = get_object_vars($value); + } + + if (is_array($value)) { + foreach ($value as $childKey => $item) { + if (is_string($childKey) && preg_match(ValueCleaner::VALUE_KEY, $childKey) !== 1) { + $problems[] = sprintf( + '%s: ungültiger Schlüssel im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-") – %s.', + $where, + self::BUILDER_ONLY + ); + } elseif (is_string($childKey)) { + $below = $where . '.' . $childKey; + $this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems); + } else { + $this->node($module, $path, $where, $item, $subName, $key, $problems); + } + } + + return; + } + + if (is_string($value)) { + $problem = $this->stringProblem($module, $path, $value, $subName, $key); + if ($problem !== null) { + $problems[] = sprintf('%s: %s', $where, $problem); + } + } + } + + /** @param list $problems */ + private function allowedPath(string $path, array &$problems): bool + { + try { + $this->cleaner->guardPath($path); + } catch (InvalidArgumentException $e) { + $problems[] = $e->getMessage(); + + return false; + } + + return true; + } + + private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string + { + if (ControlChars::strip($value) !== $value) { + return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.'; + } + + $kind = $this->cleaner->kindAt($module, $path, $subName, $key); + if (VariableRef::contains($value)) { + return $this->referenceProblem($value, $kind === ValueKind::Url); + } + + $stable = match ($kind) { + ValueKind::Html => $this->sanitizer->html($value) === $value, + ValueKind::Code => $this->sanitizer->code($value) === $value, + ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value, + ValueKind::Url => $this->sanitizer->url($value) === trim($value) || self::isPlainRelativeUrl($value), + }; + + return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' + . self::BUILDER_ONLY . '.'; + } + + /** + * Ruling P18: a link without scheme – nothing before the first "/", "?" or "#" contains ":" – and + * without whitespace, quotes, "<", ">", backslashes or control characters ("kontakt/", "/pfad", + * "#anker", "?q=1"). esc_url_raw() would prefix "http://" to some of them, yet they cannot carry + * javascript: or data:. Protocol-relative "//host" (and "\\host", which browsers read the same) + * does not count: it must survive url() unchanged. + */ + private static function isPlainRelativeUrl(string $value): bool + { + return preg_match(self::RELATIVE_URL, $value) === 1 && ! str_starts_with($value, '//'); + } + + /** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */ + private function referenceProblem(string $value, bool $url): ?string + { + try { + $canonical = $this->references->canonical($value, $this->sanitizer, $url); + } catch (InvalidArgumentException $e) { + return $e->getMessage(); + } + + $cleaned = self::normalized(VariableRef::settingsOf($canonical)); + + return $cleaned === self::normalized(VariableRef::settingsOf($value)) + ? null + : 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.'; + } + + /** + * @param array $settings + * @return array Keys sorted, scalars as strings (post_id 839 = "839"). + */ + private static function normalized(array $settings): array + { + ksort($settings); + + return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); + } +} diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php index 4da8a96..dc8bf68 100644 --- a/src/Integrations/Divi/Compose/TreeEditor.php +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Container; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; defined('ABSPATH') || exit; @@ -14,7 +15,8 @@ defined('ABSPATH') || exit; /** * Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after, * optional index), move, remove, duplicate – always checked against StructureRules. Works on - * the tree only; saving is DiviWriter's job. Pure PHP. + * the tree only; saving is DiviWriter's job. Global instances count as their root type + * (blockName, GlobalInstance::effectiveName()). Pure PHP. */ final class TreeEditor { @@ -79,7 +81,7 @@ final class TreeEditor public function insert(array $position, array $blocks): ?string { foreach ($blocks as $block) { - $violation = $this->rules->violation($position['parent'], $block->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); if ($violation !== null) { return $violation; } @@ -104,7 +106,7 @@ final class TreeEditor } } - $violation = $this->rules->violation($position['parent'], $node->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node)); if ($violation !== null) { return $violation; } diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index d81d70f..531d3f1 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -45,8 +45,8 @@ final class ValueCleaner /** Keys whose string values are URLs when the catalog names no kind (ruling S3). */ private const URL_KEY = '/(url|src|link|href|image)$/i'; - /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ - private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; + /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */ + public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ private const URL_SEGMENT = '/url$/i'; @@ -54,9 +54,14 @@ final class ValueCleaner /** * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, - * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). + * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss), + * and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the + * "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets), + * and the Lottie source (ruling P19: lottie.innerContent – src becomes data-lottie-src, and the + * lottie-web build runs animation expressions with eval). */ - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html' + . '|advanced\.customCss|css|lottie\.innerContent)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, @@ -93,7 +98,17 @@ final class ValueCleaner */ public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind { - return $this->kind($module, $path, $subKey, $subKey); + return $this->kindAt($module, $path, $subKey, $subKey); + } + + /** + * The kind clean() routes a string to that sits below the state value of $path: $subName is + * the first key there, $key the nearest key above the string (step 5, like cleanNode()). + * StoredNodeGuard checks stored values against exactly this routing. + */ + public function kindAt(string $module, string $path, ?string $subName, ?string $key): ValueKind + { + return $this->kind($module, $path, $subName, $key); } /** @@ -124,8 +139,8 @@ final class ValueCleaner { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( - '"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' - . '(oder mit dem Recht unfiltered_html).', + '"%s" (eigenes HTML, CSS, HTML-Attribute, Interaktionen oder Lottie-Quelle) ist nur im ' + . 'Divi-Builder änderbar (oder mit dem Recht unfiltered_html).', $path )); } diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 1fe416c..61260e0 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -26,7 +26,8 @@ defined('ABSPATH') || exit; * settings hue/saturation/lightness/opacity, numbers only. * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_ * (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content - * setting. "enable_html" is always rejected here. + * setting. "enable_html" is always rejected here. Internal links post_link_url_ ({{link:…}}, + * E13a) only in URL fields. * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException. */ final class VariableRef @@ -75,6 +76,25 @@ final class VariableRef return $type === 'color' || $type === 'content' ? $type : null; } + /** + * The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares + * a stored reference with canonical(): different settings mean cleaning would change them. + * + * @return array + */ + public static function settingsOf(string $value): array + { + try { + $payload = self::payloadOf($value); + } catch (InvalidArgumentException) { + return []; + } + + $settings = $payload['value']['settings'] ?? []; + + return is_array($settings) ? $settings : []; + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -232,6 +252,13 @@ final class VariableRef */ private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array { + if (! $url && str_starts_with($name, 'post_link_url_')) { + throw new InvalidArgumentException(sprintf( + '"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.', + $name + )); + } + $fields = $this->content->fieldsFor($name); $clean = []; foreach ($settings as $key => $value) { diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php index a85f7fe..065d50c 100644 --- a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -18,6 +18,11 @@ defined('ABSPATH') || exit; */ final class DiviDesignSystemAbility implements Ability { + /** Presets per answer (ruling S19); narrow with "module". */ + public const PRESET_LIMIT = 200; + + private const PARTS = ['colors', 'variables', 'presets']; + public function __construct(private DesignSystemReader $reader) { } @@ -36,12 +41,28 @@ final class DiviDesignSystemAbility implements Ability { return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und ' . '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, ' - . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.'; + . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen. ' + . 'Filter: only (colors/variables/presets) und module (Presets eines Moduls).'; } public function inputSchema(): array { - return ['type' => 'object', 'properties' => [], 'additionalProperties' => false]; + return [ + 'type' => 'object', + 'properties' => [ + 'only' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::PARTS], + 'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer ' + . 'dabei.', + ], + 'module' => [ + 'type' => 'string', + 'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".', + ], + ], + 'additionalProperties' => false, + ]; } public function outputSchema(): ?array @@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability public function execute(array $input): mixed { $design = $this->reader->read(); + $only = is_array($input['only'] ?? null) && $input['only'] !== [] + ? array_values(array_intersect(self::PARTS, $input['only'])) + : self::PARTS; + $notes = [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ]; + $skipped = array_values(array_diff(self::PARTS, $only)); + if ($skipped !== []) { + $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.'; + } + + $presets = $design->presets(); + $module = is_string($input['module'] ?? null) ? trim($input['module']) : ''; + if ($module !== '') { + $name = str_contains($module, '/') ? $module : 'divi/' . $module; + $presets = array_values(array_filter( + $presets, + static fn (array $preset): bool => $preset['module'] === $name + )); + } + if (in_array('presets', $only, true) && count($presets) > self::PRESET_LIMIT) { + $notes[] = sprintf( + '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', + self::PRESET_LIMIT, + count($presets) + ); + $presets = array_slice($presets, 0, self::PRESET_LIMIT); + } return [ - 'colors' => array_map( + 'colors' => in_array('colors', $only, true) ? array_map( static fn (array $color): array => $color + [ 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), ], $design->colors() - ), - 'variables' => array_map( + ) : [], + 'variables' => in_array('variables', $only, true) ? array_map( static fn (array $variable): array => $variable + ['placeholder' => '{{var:' . $variable['label'] . '}}'], $design->variables() - ), + ) : [], 'fonts' => $design->fonts(), 'sizes' => $design->sizes(), - 'presets' => $design->presets(), - 'notes' => [ - 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', - 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', - ], + 'presets' => in_array('presets', $only, true) ? $presets : [], + 'notes' => $notes, ]; } } diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php new file mode 100644 index 0000000..73c96e1 --- /dev/null +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -0,0 +1,97 @@ +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list Labels of the checks that failed or threw. */ + public function problems(): array + { + $problems = []; + foreach ($this->checks ?? self::defaults() as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $problems[] = (string) $label; + } + } + + return $problems; + } + + /** @return array */ + public static function defaults(): array + { + return [ + 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), + 'DynamicContentOptions (Dynamic Content)' => + static fn (): bool => class_exists(DynamicContentMap::REGISTRY), + 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), + 'Post-Typ et_pb_layout (Bibliothek)' => + static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), + 'Taxonomien scope, layout_type, module_width (Bibliothek)' => + static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], + 'GlobalLayout::is_global_layout_template (globale Module)' => + static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), + 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), + 'et_theme_builder_clear_wp_cache (Theme-Builder-Cache)' => + static fn (): bool => function_exists('et_theme_builder_clear_wp_cache'), + 'Block-Typ divi/global-layout (Instanzen globaler Module)' => + static fn (): bool => self::knowsGlobalLayout(), + 'Taxonomien layout_category, layout_tag (Bibliothek)' => + static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'), + ]; + } + + /** + * Whether Divi still has the block type of global instances (GlobalInstance::BLOCK): registered + * with WordPress, or in Divi's core module metadata (Divi renders instances itself, BlockParser, + * and does not always register the type with WordPress). + */ + private static function knowsGlobalLayout(): bool + { + if ( + class_exists('WP_Block_Type_Registry') + && \WP_Block_Type_Registry::get_instance()->is_registered(GlobalInstance::BLOCK) + ) { + return true; + } + + $registration = 'ET\Builder\Packages\ModuleLibrary\ModuleRegistration'; + if (! method_exists($registration, 'get_all_core_modules_metadata')) { + return false; + } + + foreach ((array) $registration::get_all_core_modules_metadata() as $module) { + if (is_array($module) && ($module['name'] ?? null) === GlobalInstance::BLOCK) { + return true; + } + } + + return false; + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index f825fd6..92bdc1d 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -6,14 +6,23 @@ namespace LIT\MCP\Integrations\Divi; use Closure; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; +use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\Divi\Library\LibraryIntegration; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -21,6 +30,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -39,11 +49,29 @@ final class DiviIntegration implements Integration { private DiviModules $modules; private DesignSystemReader $design; + private LibraryAccess $access; - public function __construct(private ContentSchema $schema, private ChangeLog $log) - { + /** + * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries + * (group "Divi-Bibliothek" writable); null = always. + * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group + * "Divi-Bibliothek" not disabled, ruling P9); null = always. + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy + * and lit-library-insert follow it (ruling P9, E9, audit M2); null = always. + * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled – + * lit/divi-node-copy follows it for library entries as source (audit L5); null = always. + */ + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + ?Closure $libraryWritable = null, + ?Closure $libraryVisible = null, + private ?Closure $diviWritable = null, + private ?Closure $libraryInsertable = null + ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); + $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible); } public function id(): string @@ -80,9 +108,8 @@ final class DiviIntegration implements Integration public function abilities(): array { - $loader = new DiviPageLoader($this->schema); - $composition = new DiviComposition($this->modules, $this->design); - $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); + $loader = $this->loader(); + $writer = $this->writer($loader); return [ new DiviOutlineAbility($loader, $this->design), @@ -95,6 +122,45 @@ final class DiviIntegration implements Integration new DiviDeleteAbility($writer), new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), + new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), + new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), ]; } + + /** + * The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access and its + * $diviWritable gate (lit-library-insert follows lit/divi-insert, audit M2). + */ + public function library(): LibraryIntegration + { + $loader = $this->loader(); + + return new LibraryIntegration( + $this->access, + $loader, + $this->design, + $this->writer($loader), + new DiviSaver(), + $this->log, + $this->modules, + $this->diviWritable + ); + } + + private function writer(DiviPageLoader $loader): DiviWriter + { + return new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + new DiviComposition($this->modules, $this->design), + new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())) + ); + } + + private function loader(): DiviPageLoader + { + return new DiviPageLoader($this->schema, new Parser(), $this->access); + } } diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php new file mode 100644 index 0000000..7da3e2b --- /dev/null +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -0,0 +1,229 @@ +termSlugs($id, 'scope'), true); + } + + /** "layout", "section", "row" or "module"; null when the entry has no known type. */ + public function layoutType(int $id): ?string + { + foreach ($this->termSlugs($id, 'layout_type') as $slug) { + if (in_array($slug, self::LAYOUT_TYPES, true)) { + return $slug; + } + } + + return null; + } + + /** @return list */ + public function termSlugs(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy)); + } + + /** @return list */ + public function termNames(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy)); + } + + /** + * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). + * + * @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters + * scope: "global" or "normal"; search: in the title; category: name of a layout_category + * term; author: only this user's entries. + * @return list + */ + public function find(array $filters, int $limit, int $offset): array + { + $taxQuery = []; + if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) { + $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]]; + } + if (($filters['scope'] ?? '') === 'global') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']]; + } elseif (($filters['scope'] ?? '') === 'normal') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN']; + } + if (($filters['category'] ?? '') !== '') { + $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]]; + } + + $args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + if (($filters['search'] ?? '') !== '') { + $args['s'] = $filters['search']; + // Audit L3: the title only – content is block JSON, and entries the user may not read + // must not be findable by their content. + $args['search_columns'] = ['post_title']; + } + if (is_int($filters['author'] ?? null)) { + $args['author'] = $filters['author']; + } + if ($taxQuery !== []) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. + $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery); + } + + return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post)); + } + + /** + * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string, + * categories: list, tags: list, modified: string} + */ + public function describe(WP_Post $post): array + { + $id = (int) $post->ID; + $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true); + + return [ + 'id' => $id, + 'title' => (string) $post->post_title, + 'layout_type' => $this->layoutType($id), + 'scope' => $this->isGlobal($id) ? 'global' : 'normal', + 'module_type' => $moduleType === '' ? null : $moduleType, + 'categories' => $this->termNames($id, 'layout_category'), + 'tags' => $this->termNames($id, 'layout_tag'), + 'modified' => (string) $post->post_modified_gmt, + ]; + } + + /** + * A published entry with the terms and meta of $spec and empty content (E4; the layout follows + * through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of + * lit/content-create): a refused insert or missing taxonomies create nothing; a failed term or + * meta write deletes the entry again (write_failed), or reports partial_write and logs it + * when that delete fails; a throwing insert hook is a partial write without ID. + * + * @throws WriteFailed + */ + public function create(LibraryItemSpec $spec, NewDraft $draft): int + { + $missing = $this->missingTaxonomies(); + if ($missing !== []) { + throw WriteFailed::rolledBack( + 'Die Divi-Bibliothek ist in dieser Anfrage nicht geladen.', + detail: 'Divi Library taxonomies not registered: ' . implode(', ', $missing) + ); + } + + $id = $draft->insert([ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $spec->title, + 'post_content' => '', + ]); + if ($id instanceof WP_Error) { + throw WriteFailed::rolledBack( + 'WordPress hat den Bibliothekseintrag abgelehnt.', + detail: 'wp_insert_post refused the library entry: ' . $id->get_error_message() + ); + } + + try { + $this->describeNew($id, $spec); + } catch (Throwable $e) { + $draft->discard($id, $e, self::REASON_CREATE); + } + + return $id; + } + + /** + * Library taxonomies not registered in this request (F2: Divi registers them in + * ET_Builder_Library; fixture finding 6: not in every context). + * + * @return list + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** + * Terms and meta of $spec on the new entry $id, each checked. + * + * @throws RuntimeException + */ + private function describeNew(int $id, LibraryItemSpec $spec): void + { + foreach ($spec->terms() as $taxonomy => $terms) { + $result = wp_set_object_terms($id, $terms, $taxonomy); + if ($result instanceof WP_Error) { + throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message())); + } + } + foreach ($spec->meta() as $key => $value) { + update_post_meta($id, $key, $value); + if ((string) get_post_meta($id, $key, true) !== $value) { + throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id)); + } + } + } + + /** @return list */ + private function terms(int $id, string $taxonomy): array + { + $terms = get_the_terms($id, $taxonomy); + + return is_array($terms) + ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term)) + : []; + } +} diff --git a/src/Integrations/Divi/Library/DiviRoles.php b/src/Integrations/Divi/Library/DiviRoles.php new file mode 100644 index 0000000..a28c9f0 --- /dev/null +++ b/src/Integrations/Divi/Library/DiviRoles.php @@ -0,0 +1,57 @@ + 'Divi-Bibliothek', + 'save_library' => 'In der Bibliothek speichern', + 'add_library' => 'Aus der Bibliothek hinzufügen', + 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', + 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + 'theme_builder' => 'Theme Builder', + ]; + + /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ + public function __construct(private ?Closure $check = null) + { + } + + public function allows(string $capability): bool + { + if ($this->check !== null) { + return ($this->check)($capability); + } + + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability); + } + + /** Null when the role allows $capability, else a forbidden error naming the option. */ + public function refusal(string $capability): ?WP_Error + { + if ($this->allows($capability)) { + return null; + } + + return Errors::forbidden( + sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } +} diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php new file mode 100644 index 0000000..45b8ec5 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -0,0 +1,292 @@ +backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item); + $this->trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + /** + * @param array $input + * @return array{scope: SaveScope, details: array}|WP_Error + * @throws WriteFailed When the backup could not be made (nothing was saved). + */ + public function prepare(DiviPage $page, array $input): array|WP_Error + { + $item = $page->post; + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + try { + $before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + $before = []; + } + + $violation = LibraryContent::shapeViolation( + $before, + LibraryContent::roots($page->document), + $this->access->store()->layoutType($id), + $global + ); + if ($violation !== null) { + return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]); + } + + // Audit L2: Divi renders every entry an instance names, global scope or not. + $report = $this->usages->report($id); + if (! $global && ! $report->exists()) { + return ['scope' => SaveScope::LibraryItem, 'details' => []]; + } + + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + sprintf( + '%s: die Änderung wirkt sofort auf %d %s (siehe affected), auch veröffentlichte. Nichts wurde ' + . 'gespeichert (Probelauf).', + self::label($id, $global), + $report->count, + self::pages($report->count) + ) . self::hiddenNote($report) . self::blockedNote($report), + $report->details() + ); + } + + $refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item); + if ($refusal !== null) { + return $refusal; + } + + $details = $report->listing(); + if (! wp_revisions_enabled($item)) { + $details['backup_id'] = ($this->backup)($item); + } + + return ['scope' => SaveScope::GlobalItem, 'details' => $details]; + } + + /** + * A tool's own confirm_required (lit-divi-delete) on a global or referenced entry (audit L2), + * extended by the affected pages – so the one confirm the client sends covers both questions. + */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + if (Errors::codeOf($error) !== 'confirm_required') { + return $error; + } + + $global = $this->access->isGlobal($page->post); + $report = $this->usages->report((int) $page->post->ID); + if (! $global && ! $report->exists()) { + return $error; + } + + $data = $error->get_error_data(); + $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; + + return Errors::confirmRequired( + Errors::clientMessage($error) + . sprintf( + ' %s: wirkt auf %d %s (siehe affected).', + $global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)', + $report->count, + self::pages($report->count) + ) + . self::hiddenNote($report) + . self::blockedNote($report), + array_merge($details, $report->details()) + ); + } + + /** + * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above + * GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1). + */ + private function pagesRefusal(string $label, UsageReport $report): ?WP_Error + { + if ($report->tooMany) { + return Errors::unsupported( + sprintf( + '%s erscheint auf %d veröffentlichten, privaten oder geplanten Einträgen – mehr als %d; so große ' + . 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.', + $label, + $report->live, + GlobalUsages::HARD_CAP + ), + 'Den Eintrag im Divi-Builder bearbeiten.' + ); + } + + if (! $report->blocks()) { + return null; + } + + return Errors::forbidden( + sprintf( + '%s erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto nicht ' + . 'bearbeiten darf (%s); nichts wurde gespeichert.', + $label, + self::blockedList($report) + ), + 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' + . 'oder Administrator).' + ); + } + + /** Audit L4: posts the caller may not read appear only as a count. */ + private static function hiddenNote(UsageReport $report): string + { + return $report->hidden === 0 ? '' : sprintf( + ' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).', + $report->hidden + ); + } + + private static function blockedNote(UsageReport $report): string + { + if ($report->tooMany) { + return sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante ' + . 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.', + GlobalUsages::HARD_CAP + ); + } + + return ! $report->blocks() ? '' : sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' + . 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).', + self::blockedList($report) + ); + } + + /** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */ + private static function blockedList(UsageReport $report): string + { + $parts = []; + if ($report->blocked !== []) { + $parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids'; + } + if ($report->blockedHidden > 0) { + $parts[] = sprintf( + '%d %s dieses Konto nicht lesen darf', + $report->blockedHidden, + $report->blockedHidden === 1 ? 'Eintrag, den' : 'Einträge, die' + ); + } + + return implode('; ', $parts); + } + + /** @param list $ids At most LISTED_IDS of them, then "und N weitere". */ + private static function listed(array $ids): string + { + $more = count($ids) - self::LISTED_IDS; + + return implode(', ', array_slice($ids, 0, self::LISTED_IDS)) + . ($more > 0 ? sprintf(' und %d weitere', $more) : ''); + } + + /** How messages name entry $id: global, or a normal entry that pages show as a global module (L2). */ + private static function label(int $id, bool $global): string + { + return $global + ? sprintf('Globaler Bibliothekseintrag %d', $id) + : sprintf('Bibliothekseintrag %d (auf Seiten als globales Modul verwendet)', $id); + } + + private static function pages(int $count): string + { + return $count === 1 ? 'Seite' : 'Seiten'; + } + + /** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */ + private function backupRefusal(WP_Post $item): ?WP_Error + { + if (wp_revisions_enabled($item)) { + return null; + } + + $hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder ' + . 'bearbeiten.'; + if (! current_user_can('unfiltered_html')) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses ' + . 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert ' + . '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde ' + . 'gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + return null; + } +} diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php new file mode 100644 index 0000000..adb5e66 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -0,0 +1,231 @@ +roles = $roles ?? new DiviRoles(); + } + + /** + * 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 + { + $rows = $this->rows($libraryId); + $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); + $tooMany = $withBlocked && $live > self::HARD_CAP; + $checkEdit = $withBlocked && ! $tooMany; + + $items = []; + $hidden = 0; + $blocked = []; + $blockedHidden = 0; + $checked = 0; + foreach (array_chunk($rows, self::CHUNK) as $chunk) { + $todo = array_values(array_filter( + $chunk, + static function (array $row) use (&$checked, $checkEdit): bool { + $isLive = in_array($row['status'], self::LIVE, true); + if ($checked < self::CHECK_CAP || ($checkEdit && $isLive)) { + ++$checked; + + return true; + } + + return false; + } + )); + if ($todo === []) { + continue; + } + + $ids = array_map(static fn (array $row): int => $row['id'], $todo); + $fresh = _get_non_cached_ids($ids, 'posts'); + _prime_post_caches($ids, false, false); + foreach ($todo as $row) { + $id = $row['id']; + $readable = current_user_can('read_post', $id); + if ($readable && count($items) < self::LIMIT) { + $post = get_post($id); + $items[] = [ + 'post_id' => $id, + 'title' => $post instanceof WP_Post ? (string) $post->post_title : '', + 'status' => $row['status'], + 'type' => $row['type'], + ]; + } elseif (! $readable) { + ++$hidden; + } + + if ($checkEdit && in_array($row['status'], self::LIVE, true) && ! $this->editable($row)) { + if ($readable) { + $blocked[] = $id; + } else { + ++$blockedHidden; + } + } + } + foreach ($fresh as $id) { + wp_cache_delete((int) $id, 'posts'); + } + } + + return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany); + } + + /** + * Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The + * candidates come from one query (ID, status and type only): content with "globalModule", a + * Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule + * always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by + * UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1). + * + * @return list + */ + public function rows(int $libraryId): array + { + $candidates = $this->candidates(); + $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) { + // A candidate whose content could not be read counts too (fail closed). + if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) { + $result[] = $row; + } + } + } + + return array_merge($result, array_slice($candidates, self::PARSE_CAP)); + } + + /** @return list */ + private function candidates(): array + { + $db = $this->db(); + $escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]'; + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $rows = $db->get_results( + $db->prepare( + // Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation. + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR ' + . "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " + . self::LISTED . ' ORDER BY ID ASC', + '%' . $db->esc_like('globalmodule') . '%', + '%' . $db->esc_like('global_module') . '%', + $escapedLetter + ), + 'ARRAY_A' + ); + // phpcs:enable + + $result = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $result[] = [ + 'id' => (int) $row['ID'], + 'status' => (string) $row['post_status'], + 'type' => (string) $row['post_type'], + ]; + } + + return $result; + } + + /** + * @param list $ids + * @return array Content by ID. + */ + private function contents(array $ids): array + { + $db = $this->db(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $rows = $db->get_results( + $db->prepare( + 'SELECT ID, post_content FROM ' . $db->posts . ' WHERE ID IN (' + . implode(', ', array_fill(0, count($ids), '%d')) . ')', + $ids + ), + 'ARRAY_A' + ); + // phpcs:enable + + $contents = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $contents[(int) $row['ID']] = (string) $row['post_content']; + } + + return $contents; + } + + /** + * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or + * template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own + * et_pb_is_allowed check for TB layouts, builder/functions.php). + * + * @param array{id: int, status: string, type: string} $row + */ + private function editable(array $row): bool + { + if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) { + return false; + } + + return current_user_can('edit_post', $row['id']); + } + + private function db(): \wpdb + { + return $this->db ?? $GLOBALS['wpdb']; + } +} diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php new file mode 100644 index 0000000..271715a --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -0,0 +1,179 @@ +store; + } + + public function roles(): DiviRoles + { + return $this->roles; + } + + /** + * The library entry $postId after the checks for reading, or their refusal; null when $postId + * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling + * P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID + * that does not exist, so its type and existence stay unknown; only a user with the library + * right who lacks edit_post on the entry gets forbidden (like pages). $forWrite adds canEdit(). + */ + public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null + { + $id = PostGuard::id($postId); + $post = $id === null ? null : get_post($id); + if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) { + return null; + } + + if (($this->visible !== null && ! ($this->visible)()) || $this->canList() !== null) { + return PostGuard::notFound((int) $post->ID); + } + + return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post; + } + + /** Listing the library: edit_posts and the Divi role option divi_library. */ + public function canList(): ?WP_Error + { + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.'); + } + + return $this->roles->refusal('divi_library'); + } + + /** Reading one entry: canList() and edit_post on the entry. */ + public function canRead(WP_Post $item): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + return current_user_can('edit_post', (int) $item->ID) + ? null + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); + } + + /** + * Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live + * entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route + * does not check edit_post on the entry, F9; LIT MCP does). + */ + public function canEdit(WP_Post $item): ?WP_Error + { + $refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate(); + if ($refusal !== null) { + return $refusal; + } + + if ((string) $item->post_status !== 'publish') { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.', + (int) $item->ID, + (string) $item->post_status + ), + ['post_id' => 'nur aktive Bibliothekseinträge'], + 'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.' + ); + } + + return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null; + } + + /** + * Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always + * published, F2), the role option save_library and the Tools-tab gate; a global entry + * additionally edit_global_library (ruling P7 – the same option as for editing one, F12). + */ + public function canSave(bool $global = false): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + $type = get_post_type_object(DiviLibraryStore::POST_TYPE); + $publish = is_object($type) && isset($type->cap->publish_posts) + ? (string) $type->cap->publish_posts + : 'publish_posts'; + if (! current_user_can($publish)) { + return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).'); + } + + $refusal = $this->roles->refusal('save_library') ?? $this->writableGate(); + if ($refusal !== null || ! $global) { + return $refusal; + } + + return $this->roles->refusal('edit_global_library'); + } + + /** + * Inserting from the library into a page (E3): canList(), the role option add_library (Divi's + * "Aus der Bibliothek hinzufügen") and a group "Divi-Bibliothek" that is not read-only or + * disabled (E9). Reading the entry itself stays item() (ruling P5). + */ + public function canInsert(): ?WP_Error + { + return $this->canList() ?? $this->roles->refusal('add_library') ?? $this->writableGate(); + } + + /** Refusal while the Tools tab keeps this connection from writing to the library. */ + public function writableGate(): ?WP_Error + { + if ($this->writable === null || ($this->writable)()) { + return null; + } + + return Errors::forbidden( + 'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf ' + . '„nur lesen“ oder deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + public function isGlobal(WP_Post $item): bool + { + return $this->store->isGlobal((int) $item->ID); + } +} diff --git a/src/Integrations/Divi/Library/LibraryInsertAbility.php b/src/Integrations/Divi/Library/LibraryInsertAbility.php new file mode 100644 index 0000000..08462c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryInsertAbility.php @@ -0,0 +1,271 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'], + 'mode' => ['type' => 'string', 'enum' => self::MODES], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'library_id'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + protected function precheck(array $input): ?WP_Error + { + $refusal = $this->access->canInsert(); + if ($refusal !== null) { + return $refusal; + } + + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $mode = $input['mode'] ?? null; + if ($mode !== null && ! in_array($mode, self::MODES, true)) { + return Errors::invalidInput('mode ist „copy“ oder „global“; nichts wurde gespeichert.', [ + 'mode' => 'copy oder global', + ]); + } + + $item = $this->item($input['library_id'] ?? null); + if ($item instanceof WP_Error) { + return $item; + } + + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + $mode = is_string($mode) ? $mode : ($global ? 'global' : 'copy'); + if ($mode === 'global' && ! $global) { + return Errors::invalidInput( + 'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.', + ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden'] + ); + } + + $blocks = $this->blocks($item, $mode === 'global', $global); + if ($blocks instanceof WP_Error) { + return $blocks; + } + + $guard = $composition->storedGuard(); + foreach ($blocks as $block) { + $problems = $guard->problems($block); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; ' + . 'nichts wurde gespeichert.', + ['library_id' => implode('; ', $problems)] + ); + } + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, $blocks); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.', + ['library_id' => $violation] + ); + } + + $change = new DiviChange(sprintf( + 'Bibliothekseintrag „%s“ (%d) %s eingefügt', + (string) $item->post_title, + $id, + $mode === 'global' ? 'als globale Instanz' : 'als Kopie' + )); + foreach ($blocks as $block) { + $change->record($block, 'inserted'); + } + if ($mode === 'global') { + $change->warning(sprintf( + 'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit ' + . 'diesem Modul (lit-divi-outline mit post_id %d).', + $id, + $id + )); + } + + return $change; + } + + /** + * The active library entry $libraryId after LibraryAccess' read checks (ruling P5: the library + * right and edit_post on the entry), or the refusal. Anything else is not found. + */ + private function item(mixed $libraryId): WP_Post|WP_Error + { + $id = PostGuard::id($libraryId); + if ($id === null) { + return Errors::invalidInput('library_id fehlt oder ist keine ID; nichts wurde gespeichert.', [ + 'library_id' => 'ID aus lit/library-list', + ]); + } + + $item = $this->access->item($id, false); + if ($item instanceof WP_Error) { + return $item; + } + + if (! $item instanceof WP_Post || (string) $item->post_status !== 'publish') { + return Errors::notFound( + sprintf('Bibliothekseintrag %d gibt es nicht (oder er ist nicht aktiv).', $id), + 'Einträge mit lit/library-list auflisten.' + ); + } + + return $item; + } + + /** @return list|WP_Error The blocks to insert. */ + private function blocks(WP_Post $item, bool $instance, bool $global): array|WP_Error + { + $id = (int) $item->ID; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + return Errors::unsupported( + sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id), + 'Den Eintrag im Divi-Builder öffnen und speichern.' + ); + } + + if ($roots === []) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id), + ['library_id' => 'leer'] + ); + } + + if ($instance) { + return $this->instance($roots, $id); + } + + return array_map( + static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(), + $roots + ); + } + + /** + * The one instance of entry $id (GlobalInstance::of). Ruling P11: never nested instances – the + * root must be a single readable node holding no global module (Divi forbids nesting, F10). + * + * @param list $roots + * @return list|WP_Error + */ + private function instance(array $roots, int $id): array|WP_Error + { + if (count($roots) !== 1) { + return Errors::invalidInput( + 'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde ' + . 'gespeichert.', + ['mode' => 'mode "copy" verwenden'] + ); + } + + $root = $roots[0]; + if ($root->isOpaque() || LibraryContent::containsGlobal($root)) { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist selbst ein globales Modul, enthält eines oder ist nicht lesbar; als ' + . 'Instanz entstünde eine Verschachtelung, die Divi nicht erlaubt. Nichts wurde gespeichert.', + $id + ), + ['mode' => 'mode "copy" verwenden oder den Eintrag im Divi-Builder prüfen'] + ); + } + + return [GlobalInstance::of($root, $id)]; + } +} diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php new file mode 100644 index 0000000..1b2d750 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -0,0 +1,74 @@ +access, + $this->loader, + $this->design, + new GlobalUsages(null, $this->access->roles()) + ), + new LibrarySaveAbility( + $this->access, + $this->loader, + $this->writer, + $this->saver, + $this->log, + $this->modules + ), + new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable), + ]; + } +} diff --git a/src/Integrations/Divi/Library/LibraryItemSpec.php b/src/Integrations/Divi/Library/LibraryItemSpec.php new file mode 100644 index 0000000..e5fa4c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryItemSpec.php @@ -0,0 +1,148 @@ + $categories layout_category term IDs. + * @param list $tags layout_tag term IDs. + */ + public function __construct( + public readonly string $title, + public readonly string $layoutType, + public readonly bool $global, + public readonly string $moduleWidth, + public readonly ?string $moduleType, + public readonly string $builtFor, + public readonly array $categories = [], + public readonly array $tags = [] + ) { + } + + /** + * The entry that holds $node, or the German reason it cannot be saved: sections, rows and + * modules only (Divi's "add to library"); a global module is already an entry; a global entry + * holds no global module (F10). Opaque roots and global instances are refused before anything + * wraps them (ruling P11), so no entry ever starts with an instance. + */ + public static function forNode( + Block $node, + ModuleCatalog $catalog, + string $title, + bool $global, + string $builtFor + ): self|string { + if ($node->isGlobal()) { + $ref = NodeSummary::globalRef($node); + + return sprintf( + 'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert ' + . 'einfügen statt erneut speichern.', + $ref['id'] ?? '?' + ); + } + if ($node->isOpaque()) { + return sprintf( + 'Die Einstellungen von %s sind nicht lesbar; der Knoten lässt sich nicht in der Bibliothek speichern.', + $node->name() + ); + } + + $name = $node->name(); + if ($name === 'divi/section') { + $type = 'section'; + $width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular'; + } elseif ($name === 'divi/row') { + $type = 'row'; + $width = 'regular'; + } elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) { + $type = 'module'; + $width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular'; + } else { + return sprintf( + '%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).', + $name + ); + } + + if ($global && LibraryContent::containsGlobal($node)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung); als normalen Eintrag speichern.'; + } + + return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor); + } + + /** + * @param list $categories + * @param list $tags + */ + public function withTerms(array $categories, array $tags): self + { + return new self( + $this->title, + $this->layoutType, + $this->global, + $this->moduleWidth, + $this->moduleType, + $this->builtFor, + $categories, + $tags + ); + } + + /** @return array> Taxonomy => slugs (system terms) or term IDs. */ + public function terms(): array + { + $terms = [ + 'scope' => [$this->global ? 'global' : 'non_global'], + 'layout_type' => [$this->layoutType], + 'module_width' => [$this->moduleWidth], + ]; + if ($this->categories !== []) { + $terms['layout_category'] = $this->categories; + } + if ($this->tags !== []) { + $terms['layout_tag'] = $this->tags; + } + + return $terms; + } + + /** @return array */ + public function meta(): array + { + $meta = []; + if ($this->moduleType !== null) { + $meta['_et_pb_module_type'] = $this->moduleType; + } + if ($this->global && $this->layoutType === 'module') { + $meta['_et_pb_excluded_global_options'] = '[]'; + } + $meta['_et_pb_built_for_post_type'] = $this->builtFor; + $meta['_et_pb_template_type'] = $this->layoutType; + + return $meta; + } +} diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php new file mode 100644 index 0000000..48f8862 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -0,0 +1,263 @@ + 'object', + 'properties' => array_merge([ + 'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'], + 'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES], + 'scope' => ['type' => 'string', 'enum' => ['normal', 'global']], + 'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'], + 'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'], + ], Cursor::inputProperties()), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'layout_type' => ['type' => ['string', 'null']], + 'scope' => ['type' => 'string'], + 'module_type' => ['type' => ['string', 'null']], + 'categories' => ['type' => 'array', 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'items' => ['type' => 'string']], + 'modified' => ['type' => 'string'], + ], + 'required' => [ + 'id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified', + ], + ], + ], + 'next_cursor' => ['type' => ['string', 'null']], + 'outline' => ['type' => 'string'], + 'tree_rev' => ['type' => 'string'], + 'rev' => ['type' => 'string'], + 'edit_url' => ['type' => 'string'], + 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], + 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['items', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + if (isset($input['id'])) { + $page = $this->entry($input['id']); + + return $page instanceof WP_Error ? $page : true; + } + + return $this->access->canList() ?? true; + } + + public function execute(array $input): mixed + { + if (isset($input['id'])) { + $page = $this->entry($input['id']); + + return $page instanceof WP_Error ? $page : $this->detail($page); + } + + $refusal = $this->access->canList(); + if ($refusal !== null) { + return $refusal; + } + + $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $filters = array_filter( + [ + 'layout_type' => $input['layout_type'] ?? null, + 'scope' => $input['scope'] ?? null, + 'search' => $input['search'] ?? null, + 'category' => $input['category'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + // Audit L3: without edit_others_posts the query holds the own entries only (PostGuard). + $author = PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE); + if ($author !== null) { + $filters['author'] = $author; + } + + return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset); + } + + /** + * One page of the entries the user may edit. The cursor counts the entries this user was shown + * ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call + * reads the rows from the start again and skips the first $skip readable ones. next_cursor is + * null when no readable entry follows (or the scan cap is reached with nothing found). + * + * @param array $filters + * @return array{items: list>, next_cursor: ?string, notes?: list} + */ + private function page(array $filters, int $limit, int $skip): array + { + $store = $this->access->store(); + $items = []; + $readable = 0; + $position = 0; + while ($position < $skip + self::MAX_SCAN) { + $rows = $store->find($filters, $limit, $position); + foreach ($rows as $post) { + ++$position; + if ($this->access->canRead($post) !== null || $readable++ < $skip) { + continue; + } + if (count($items) === $limit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($skip + $limit)]; + } + $items[] = $store->describe($post); + } + if (count($rows) <= $limit) { + return ['items' => $items, 'next_cursor' => null]; + } + } + + // Review M-12: say why the page is short, so the client neither thinks the list ended nor loops. + return [ + 'items' => $items, + 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items)), + 'notes' => [sprintf( + 'Nach %d gelesenen Bibliothekseinträgen abgebrochen (Einträge ohne Bearbeitungsrecht werden ' + . 'übersprungen); %s', + self::MAX_SCAN, + $items === [] + ? 'weitere Einträge mit Filtern (layout_type, scope, search, category) eingrenzen.' + : 'mit next_cursor weiterblättern oder mit Filtern eingrenzen.' + )], + ]; + } + + /** + * The library entry $id loaded for reading. Anything that is not a library entry reads like a + * missing ID, so the tool tells nothing about other posts (ruling P12). + */ + private function entry(mixed $id): DiviPage|WP_Error + { + $postId = PostGuard::id($id); + if ($postId === null) { + return Errors::invalidInput('id ist keine gültige Eintrags-ID.', ['id' => 'positive Ganzzahl']); + } + + $item = $this->access->item($postId, false); + if (! $item instanceof WP_Post) { + return $item ?? PostGuard::notFound($postId); + } + + $page = $this->loader->load($postId, false); + if ($page instanceof WP_Error) { + return $page; + } + + return $page->isLibraryItem() ? $page : PostGuard::notFound($postId); + } + + /** @return array */ + private function detail(DiviPage $page): array + { + $item = $this->access->store()->describe($page->post); + $outline = new Outline($this->design->read()->presetNames()); + $result = [ + 'items' => [$item], + 'next_cursor' => null, + 'outline' => $outline->text($outline->rows($page->index)), + 'tree_rev' => $page->treeRev(), + 'rev' => Rev::ofPost($page->post), + 'edit_url' => $page->editUrl(), + ]; + // Audit L2: a normal entry that pages show as an instance names them too. + $report = $this->usages->report((int) $page->post->ID, false); + if ($item['scope'] === 'global' || $report->exists()) { + $result += $report->listing(); + } + + return $result; + } +} diff --git a/src/Integrations/Divi/Library/LibrarySaveAbility.php b/src/Integrations/Divi/Library/LibrarySaveAbility.php new file mode 100644 index 0000000..5e3e1f0 --- /dev/null +++ b/src/Integrations/Divi/Library/LibrarySaveAbility.php @@ -0,0 +1,284 @@ + 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; + + private const SCOPES = ['normal', 'global']; + + private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.'; + + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function name(): string + { + return 'lit/library-save'; + } + + public function label(): string + { + return 'In der Divi-Bibliothek speichern'; + } + + public function description(): string + { + return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – ' + . 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die ' + . 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit ' + . 'lit-library-insert (mode "global") auf Seiten einfügen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Seite (oder Eintrag) mit dem Knoten.', + ], + 'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'], + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'scope' => ['type' => 'string', 'enum' => self::SCOPES, 'description' => 'Standard: normal.'], + 'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'id', 'title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + ['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']], + ['library_id', 'tree_rev'] + ); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $refusal = $this->access->canSave(self::isGlobal($input)); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + /** @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6). */ + public function execute(array $input): mixed + { + if (! in_array($input['scope'] ?? 'normal', self::SCOPES, true)) { + return Errors::invalidInput('scope ist „normal“ oder „global“; nichts wurde angelegt.', [ + 'scope' => 'normal oder global', + ]); + } + + $refusal = $this->access->canSave(self::isGlobal($input)); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.', + DiviPageLoader::OUTLINE_HINT + ); + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + $node = $page->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $page->post); + } + + $title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : ''); + if ($title === '') { + return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']); + } + + $spec = LibraryItemSpec::forNode( + $node, + $this->modules->catalog(), + $title, + self::isGlobal($input), + $this->builtFor($page) + ); + if (is_string($spec)) { + return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]); + } + + $problems = $this->writer->composition($page)->storedGuard()->problems($node); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'angelegt.', + ['id' => implode('; ', $problems)] + ); + } + + $categories = $this->termIds('layout_category', $input['categories'] ?? []); + $tags = $this->termIds('layout_tag', $input['tags'] ?? []); + foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) { + if (is_string($ids)) { + return Errors::invalidInput( + 'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.', + [$field => $ids] + ); + } + } + + return $this->create($page, $spec->withTerms($categories, $tags), self::content($node), $input); + } + + /** + * @param array $input + * @return array + * @throws WriteFailed + */ + private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array + { + $draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag'); + $libraryId = $this->access->store()->create($spec, $draft); + + $warnings = []; + try { + $warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem); + } catch (Throwable $e) { + $draft->discard($libraryId, $e, self::REASON_LAYOUT); + } + + $followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings)) + ->afterSave($input); + $created = $followUp->reread(); + $kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : ''); + $followUp->log( + DiviLibraryStore::POST_TYPE, + sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID) + ); + + $result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created) + ->with('library_id', $libraryId) + ->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content)) + ->urls(null, (string) get_edit_post_link($libraryId, 'raw')); + if ($spec->global) { + $result->warning( + 'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert ' + . '(mode "global") einfügen.' + ); + } + + return $result->toArray(); + } + + /** The node's stored bytes framed like the builder's "add to library", line breaks unified. */ + private static function content(Block $node): string + { + $copy = $node->copy(); + $copy->unifyLineBreaks(); + + return LibraryContent::wrap($copy); + } + + /** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */ + private function builtFor(DiviPage $page): string + { + if (! $page->isLibraryItem()) { + return (string) $page->post->post_type; + } + + $builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true); + + return $builtFor !== '' ? $builtFor : 'page'; + } + + /** @param array $input */ + private static function isGlobal(array $input): bool + { + return ($input['scope'] ?? 'normal') === 'global'; + } + + /** @return list|string Term IDs of existing terms by name, or the problem. */ + private function termIds(string $taxonomy, mixed $names): array|string + { + $ids = []; + foreach (is_array($names) ? $names : [] as $name) { + $term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false; + if (! $term instanceof WP_Term) { + return sprintf( + '„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).', + is_string($name) ? $name : '?' + ); + } + $ids[] = (int) $term->term_id; + } + + return array_values(array_unique($ids)); + } +} diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php new file mode 100644 index 0000000..96c47c0 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -0,0 +1,177 @@ +" Divi's render takes (leading zeros = intval), and + * every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a + * globalModule with that ID at any depth; + * - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an + * opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module + * cast with (int) like Divi; a non-empty value other than "0" that casts to 0 makes Divi render the + * newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry; + * - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content + * the regex engine cannot scan, counts as a possible reference. + * Pure PHP. + */ +final class UsageMatcher +{ + /** A block opener and its attribute JSON (the Parser's delimiter grammar). */ + private const OPENER = '/).)*+)?})\s+\/?-->/s'; + + /** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */ + private const TAG = '/\[\[?([\w-]+)(?![\w-])([^\]\/]*(?:\/(?!\])[^\]\/]*)*?)\/?\]/'; + + /** WordPress' get_shortcode_atts_regex(). */ + private const ATTS = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*' + . '([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/'; + + public static function shows(string $content, int $id): bool + { + $textual = preg_match('/"globalModule":"0*' . $id . '"/', $content); + if ($textual !== 0) { + return true; // A match, or a regex failure (fail closed). + } + + $found = preg_match_all(self::OPENER, $content, $matches); + if ($found === false) { + return true; + } + + // Shortcodes outside the openers (Divi 4 pages, free text between blocks). + $outside = $found === 0 ? $content : str_replace($matches[1], '', $content); + if (self::shortcodeShows($outside, $id)) { + return true; + } + + $escape = chr(92) . 'u'; + foreach ($matches[1] as $json) { + if ( + ! str_contains($json, $escape) + && ! str_contains($json, 'globalModule') + && stripos($json, 'global_module') === false + ) { + continue; + } + $attrs = json_decode($json, true); + if (! is_array($attrs) || self::names($attrs, $id)) { + return true; + } + foreach (self::strings($attrs) as $value) { + if (self::shortcodeShows($value, $id)) { + return true; + } + } + } + + return false; + } + + /** Whether a Divi 4 shortcode in $text renders entry $id (or, casting to 0, any entry). */ + private static function shortcodeShows(string $text, int $id): bool + { + if (stripos($text, 'global_module') === false) { + return false; + } + + $found = preg_match_all(self::TAG, $text, $tags); + if ($found === false) { + return true; + } + foreach ($tags[2] as $attributes) { + $atts = self::parseAtts($attributes); + if ($atts === null) { + return true; + } + $value = $atts['global_module'] ?? null; + // Divi: ! empty( $global_module_id ), then WP_Query p => (int) $global_module_id. + if (! is_string($value) || $value === '' || $value === '0') { + continue; + } + $cast = (int) $value; + if ($cast === 0 || $cast === $id) { + return true; + } + } + + return false; + } + + /** + * shortcode_parse_atts() of WordPress 6.9 (named attributes only); null when PCRE fails. + * + * @return array|null + */ + private static function parseAtts(string $text): ?array + { + $text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text); + if (! is_string($text)) { + return null; + } + + $found = preg_match_all(self::ATTS, $text, $matches, PREG_SET_ORDER); + if ($found === false) { + return null; + } + + $atts = []; + foreach ($matches as $m) { + foreach ([1, 3, 5] as $key) { + if (! empty($m[$key])) { + $atts[strtolower($m[$key])] = stripcslashes($m[$key + 1]); + break; + } + } + } + + // WordPress rejects values with unclosed HTML elements. + foreach ($atts as $name => $value) { + if (str_contains($value, '<') && preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value) !== 1) { + $atts[$name] = ''; + } + } + + return $atts; + } + + /** @param array $attrs Whether a globalModule key at any depth names $id. */ + private static function names(array $attrs, int $id): bool + { + foreach ($attrs as $key => $value) { + if ( + $key === 'globalModule' + && (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1)) + && (int) $value === $id + ) { + return true; + } + if (is_array($value) && self::names($value, $id)) { + return true; + } + } + + return false; + } + + /** + * @param array $attrs + * @return list Every string value at any depth. + */ + private static function strings(array $attrs): array + { + $strings = []; + array_walk_recursive($attrs, static function (mixed $value) use (&$strings): void { + if (is_string($value)) { + $strings[] = $value; + } + }); + + return $strings; + } +} diff --git a/src/Integrations/Divi/Library/UsageReport.php b/src/Integrations/Divi/Library/UsageReport.php new file mode 100644 index 0000000..d04e6b6 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageReport.php @@ -0,0 +1,61 @@ + $items Readable + * posts (at most GlobalUsages::LIMIT). + * @param int $hidden Posts the caller may not read. + * @param int $live Published, private and scheduled posts among them (ruling P8a). + * @param list $blocked Readable live posts the caller may not edit. + * @param int $blockedHidden Unreadable live posts the caller may not edit. + * @param bool $tooMany More live posts than GlobalUsages::HARD_CAP – not checked one by one (audit L1). + */ + public function __construct( + public readonly int $count, + public readonly array $items, + public readonly int $hidden, + public readonly int $live, + public readonly array $blocked, + public readonly int $blockedHidden, + public readonly bool $tooMany + ) { + } + + public function exists(): bool + { + return $this->count > 0; + } + + /** Whether a confirmed edit would be refused (ruling P8a, audit L1). */ + public function blocks(): bool + { + return $this->tooMany || $this->blocked !== [] || $this->blockedHidden > 0; + } + + /** @return array affected, affected_count and, when there are any, hidden_count. */ + public function listing(): array + { + return ['affected' => $this->items, 'affected_count' => $this->count] + + ($this->hidden > 0 ? ['hidden_count' => $this->hidden] : []); + } + + /** @return array listing() plus blocked_ids and blocked_hidden_count when there are any. */ + public function details(): array + { + return $this->listing() + + ($this->blocked !== [] ? ['blocked_ids' => $this->blocked] : []) + + ($this->blockedHidden > 0 ? ['blocked_hidden_count' => $this->blockedHidden] : []); + } +} diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index df4ea27..c81604d 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -6,9 +6,11 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; +use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; @@ -45,7 +47,8 @@ class DiviComposition private DesignSystemReader $design, private ValueSanitizer $sanitizer = new WpValueSanitizer(), private MediaResolver $media = new WpMediaResolver(), - private DynamicContentMap $dynamicContent = new DynamicContentMap() + private DynamicContentMap $dynamicContent = new DynamicContentMap(), + private LinkTargets $links = new WpLinkTargets() ) { } @@ -83,7 +86,7 @@ class DiviComposition */ public function placeholders(): Placeholders { - return $this->placeholders ??= new Placeholders($this->design->read()); + return $this->placeholders ??= new Placeholders($this->design->read(), $this->links); } /** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */ @@ -116,6 +119,12 @@ class DiviComposition return new StructureRules($this->modules->catalog()); } + /** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */ + public function storedGuard(): StoredNodeGuard + { + return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer); + } + public function editor(): TreeEditor { return new TreeEditor($this->rules()); diff --git a/src/Integrations/Divi/Pages/DiviDeleteAbility.php b/src/Integrations/Divi/Pages/DiviDeleteAbility.php index 1b2a9be..0d52bea 100644 --- a/src/Integrations/Divi/Pages/DiviDeleteAbility.php +++ b/src/Integrations/Divi/Pages/DiviDeleteAbility.php @@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility 'items' => ['type' => 'string'], 'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).', ], - 'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'], ]), 'required' => ['post_id', 'tree_rev', 'ids'], 'additionalProperties' => false, diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php new file mode 100644 index 0000000..9863548 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -0,0 +1,301 @@ +}}. + * + * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods + * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each + * left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The + * field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25). + */ +final class DiviDynamicSourcesAbility implements Ability +{ + /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ + private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice']; + + private const ID_TYPES = ['relation', 'file']; + + /** Candidates the default sample lookup checks for edit_post. */ + private const SAMPLE_CANDIDATES = 10; + + /** @var Closure(string): int */ + private Closure $samplePost; + + /** @var Closure(string): ?string */ + private Closure $podStorage; + + /** + * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). + * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"); + * null without a pod. + */ + public function __construct( + private ContentSchema $schema, + private DynamicContentMap $map, + private DiviRoles $roles, + ?Closure $samplePost = null, + ?Closure $podStorage = null + ) { + $this->samplePost = $samplePost ?? self::defaultSample(...); + $this->podStorage = $podStorage ?? self::defaultStorage(...); + } + + public function name(): string + { + return 'lit/divi-dynamic-sources'; + } + + public function label(): string + { + return 'Dynamic-Content-Quellen'; + } + + public function description(): string + { + return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' + . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' + . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + $entry = static fn (array $properties): array => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => array_fill_keys($properties, ['type' => 'string']), + 'required' => $properties, + ], + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'post_type' => ['type' => 'string'], + 'sample_post_id' => ['type' => 'integer'], + 'sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], + ], + ], + 'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']), + 'omitted' => $entry(['key', 'label', 'reason']), + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $sample = ($this->samplePost)($postType); + $notes = [ + 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' + . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', + ]; + + $options = $this->map->options($sample); + if ($options === null) { + $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; + $options = []; + foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { + $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; + } + } + + $sources = []; + foreach ($options as $name => $option) { + $sources[] = [ + 'name' => (string) $name, + 'label' => $option['label'], + 'group' => $option['group'], + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($option['settings']), + ]; + } + + $fields = []; + $omitted = []; + if ($this->roles->allows('read_dynamic_content_custom_fields')) { + [$fields, $omitted] = $this->fields($postType); + } else { + $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' + . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; + } + + return [ + 'post_type' => $postType, + 'sample_post_id' => $sample, + 'sources' => $sources, + 'fields' => $fields, + 'omitted' => $omitted, + 'notes' => $notes, + ]; + } + + private static function placeholder(string $name): ?string + { + $post = array_search($name, Placeholders::POST, true); + if ($post !== false) { + return '{{post:' . $post . '}}'; + } + + if (! str_starts_with($name, 'post_link_url_')) { + return null; + } + + // Review M-9: {{link:}} refuses attachments (status inherit); internal types ("_…") are no link targets. + $type = substr($name, strlen('post_link_url_')); + + return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:}}'; + } + + /** + * @return array{0: list>, 1: list>} Offered and omitted fields. + */ + private function fields(string $postType): array + { + $storage = ($this->podStorage)($postType); + $fields = []; + $omitted = []; + foreach ($this->schema->fields($postType) as $field) { + if ($field->status === FieldDefinition::HIDDEN) { + continue; + } + + $entry = ['key' => $field->key, 'label' => $field->label]; + $reason = self::omission($field, $storage); + if ($reason !== null) { + $omitted[] = $entry + ['reason' => $reason]; + continue; + } + + $fields[] = $entry + [ + 'type' => $field->type, + 'source' => $field->source, + 'placeholder' => '{{field:' . $field->key . '}}', + ]; + } + + return [$fields, $omitted]; + } + + private static function omission(FieldDefinition $field, ?string $storage): ?string + { + return match (true) { + ! DynamicContentRules::isMetaKey($field->key) + => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', + $field->source === 'pods' && $storage === 'table' + => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', + in_array($field->type, self::ID_TYPES, true) + => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', + $field->type === 'unknown' + => 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text', + ! in_array($field->type, self::TEXT_TYPES, true) + => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), + default => null, + }; + } + + /** The most recently modified entry of the type the current user may edit, else 0. */ + private static function defaultSample(string $postType): int + { + $ids = get_posts([ + 'post_type' => $postType, + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => self::SAMPLE_CANDIDATES, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + foreach (is_array($ids) ? $ids : [] as $id) { + if (current_user_can('edit_post', (int) $id)) { + return (int) $id; + } + } + + return 0; + } + + private static function defaultStorage(string $postType): ?string + { + if (! function_exists('pods_api')) { + return null; + } + + try { + $pod = pods_api()->load_pod(['name' => $postType]); + } catch (Throwable) { + return null; + } + + return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; + } +} diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php new file mode 100644 index 0000000..ec99827 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -0,0 +1,173 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'source_post_id' => ['type' => 'integer', 'minimum' => 1], + 'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'], + 'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** + * lit/divi-insert's Tools-tab state, then the source's own rights and, for a library entry as + * source, the rights of an insert from the library – in permission() and execute(). + */ + protected function precheck(array $input): ?WP_Error + { + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + return $source->isLibraryItem() ? $this->libraryInsertRefusal() : null; + } + + /** E3/audit L5: copying out of a library entry is inserting from the library (lit-library-insert). */ + private function libraryInsertRefusal(): ?WP_Error + { + if ($this->libraryInsertable !== null && ! ($this->libraryInsertable)()) { + return Errors::unsupported( + 'Einfügen aus der Divi-Bibliothek ist für diese Verbindung abgeschaltet (lit-library-insert ' + . 'deaktiviert oder Gruppe „Divi-Bibliothek“ auf „nur lesen“).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return $this->loader->library()?->canInsert(); + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) { + return Errors::revMismatch( + 'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde ' + . 'gespeichert.', + 'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.' + ); + } + + $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : ''; + $node = $source->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $source->post); + } + + $copy = $node->copy(); + if ($source->isLibraryItem()) { + // Finding 9: library entries from Divi's dialog use CRLF; unified before every takeover + // (LibraryContent::load), as lit-library-insert and lit/library-save do. + $copy->unifyLineBreaks(); + } + $problems = $composition->storedGuard()->problems($copy); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'gespeichert.', + ['source_id' => implode('; ', $problems)] + ); + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, [$copy]); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.', + ['source_id' => $violation] + ); + } + + return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID))) + ->record($copy, 'inserted'); + } +} diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index 425bff7..fd1f761 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability ], 'builder_version' => ['type' => ['string', 'null']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'global' => ['type' => ['object', 'null']], ], 'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], ]; @@ -128,6 +129,16 @@ final class DiviNodeGetAbility implements Ability $editable = $block->isDivi() && ! $block->isOpaque(); $version = $block->attrs()['builderVersion'] ?? null; + $global = NodeSummary::globalRef($block); + if ($global !== null) { + $global['hint'] = sprintf( + 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' + . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', + $global['id'], + $global['id'] + ); + } + return [ 'post_id' => (int) $page->post->ID, 'tree_rev' => $page->treeRev(), @@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability 'children' => $editable ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) : [], + 'global' => $global, ]; } } diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 7151b50..70026d9 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability 'type' => 'boolean', 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', ], + 'global_id' => [ + 'type' => ['string', 'null'], + 'description' => 'Bibliotheks-ID eines globalen Moduls.', + ], ], 'required' => [ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', - 'truncated', + 'truncated', 'global_id', ], ], ], @@ -187,7 +191,8 @@ final class DiviOutlineAbility implements Ability if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' - . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; + . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; } return $hints; diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php index 6c4a10a..318f4f4 100644 --- a/src/Integrations/Divi/Pages/DiviPage.php +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -21,10 +21,17 @@ final class DiviPage public function __construct( public readonly WP_Post $post, public readonly Document $document, - public readonly NodeIndex $index + public readonly NodeIndex $index, + private bool $libraryItem = false ) { } + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ + public function isLibraryItem(): bool + { + return $this->libraryItem; + } + public function treeRev(): string { return Rev::of((string) $this->post->post_content); @@ -40,7 +47,10 @@ final class DiviPage public function editUrl(): string { - return self::builderUrl($this->post); + // A library entry has no public permalink (public: false); Divi's library list opens it here. + return $this->libraryItem + ? (string) get_edit_post_link($this->post, 'raw') + : self::builderUrl($this->post); } /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */ diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 57ceb6d..5da88a3 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Log; use WP_Error; +use WP_Post; defined('ABSPATH') || exit; /** - * Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the - * block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint – - * spec §3.4: Divi 4 content is kept, never edited. + * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other + * entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4 + * shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content + * is kept, never edited. */ final class DiviPageLoader { public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die ' . 'Änderung mit der neuen tree_rev wiederholen.'; - public function __construct(private ContentSchema $schema, private Parser $parser = new Parser()) + public function __construct( + private ContentSchema $schema, + private Parser $parser = new Parser(), + private ?LibraryAccess $library = null + ) { + } + + /** The library access the Divi tools use, null when library entries are not reachable. */ + public function library(): ?LibraryAccess { + return $this->library; } /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - $post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + // Library entries (E1): with a library access, for reading and writing. + $item = $this->library?->item($postId, $forWrite); + $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; } @@ -64,7 +78,7 @@ final class DiviPageLoader ); } - return new DiviPage($post, $document, NodeIndex::build($document)); + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); } /** The entry loaded for writing and checked by writable() – the refusal of either step. */ @@ -79,13 +93,14 @@ final class DiviPageLoader } /** - * Additional checks for writing: the type must use the Divi builder, the content must be a - * Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). + * Additional checks for writing: the type must use the Divi builder (library entries always do), + * the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule + * (spec §2). */ public function writable(DiviPage $page): ?WP_Error { $post = $page->post; - if (! $this->schema->diviEnabled((string) $post->post_type)) { + if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) { return Errors::unsupported( sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), 'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).' diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 88bf14e..7b3e433 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -29,9 +29,11 @@ defined('ABSPATH') || exit; * * Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is * computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of - * it is written; DiviWriter::save() stores it with its own restore-point revision and change - * log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged - * and reported there, the others go on. Inserted text is cleaned by the field kind through + * it is written; DiviWriter::commit() stores it with its own restore-point revision and change + * log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never + * changed here – the target reports the pages it shows on, answer F2). The batch reports every + * page (spec §8.2, ruling S10): any Throwable of one page is logged and reported there, the + * others go on. Inserted text is cleaned by the field kind through * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched. */ final class DiviReplaceTextAbility implements Ability @@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability $change->record($block, 'updated'); } - $saved = $this->writer->save($this->name(), $page, $change, $input); + // Ruling P1: through commit() like every Divi tool – library entries keep their shape and get + // no Divi flags. $input never carries confirm, so a global entry only answers with its dry + // run (the pages it would change, answer F2) and is never saved from here. + $saved = $this->writer->commit($this->name(), $page, $change, $input); + if ($saved instanceof WP_Error) { + return $base + self::refused($saved); + } return $base + [ 'ok' => true, @@ -378,6 +386,27 @@ final class DiviReplaceTextAbility implements Ability ]; } + /** + * A page commit() refused: its message, and for a global library entry the pages it shows on. + * + * @return array + */ + private static function refused(WP_Error $error): array + { + $data = $error->get_error_data(); + if (Errors::codeOf($error) !== 'confirm_required' || ! is_array($data)) { + return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.']; + } + + return [ + 'ok' => false, + 'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, ' + . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', + 'affected' => $data['affected'] ?? [], + 'affected_count' => $data['affected_count'] ?? 0, + ] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []); + } + /** * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's * own composition (ruling S22). diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 940af63..5cbaa39 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -40,16 +40,20 @@ defined('ABSPATH') || exit; * those hooks except the content itself (already cleaned); anything a third-party hook writes * there is saved without kses, as it would be for an administrator. The rollback write in * restore() uses the same window with the previous stored content; saveNew() (the first layout - * of a new draft, lit/content-create) uses it for its single wp_update_post(). + * of a new post – a draft of lit/content-create or a library entry of lit/library-save) uses it + * for its single wp_update_post(). * * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data * AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and - * DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first two re-serialize - * the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such + * DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first + * two re-serialize the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such * pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * + * SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears + * the static CSS of every page and the Theme Builder cache. + * * If saving throws, the previous content is written back (WriteFailed: rolled back or not). * Not final so abilities can be tested with a mock. */ @@ -64,7 +68,7 @@ class DiviSaver * @return array{revision_id: ?int, warnings: list} * @throws WriteFailed */ - public function save(WP_Post $post, string $content): array + public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; $revisionId = null; @@ -90,17 +94,27 @@ class DiviSaver } $warnings = []; + if ($scope->setsFlags()) { + FollowUp::run( + static fn () => DiviFlags::set($postId), + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + } FollowUp::run( - static fn () => DiviFlags::set($postId), - 'divi builder flags', - 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' - . 'einmal im Divi-Builder öffnen und speichern.', - $warnings - ); - FollowUp::run( - static function () use ($postId): void { - if (class_exists('ET_Core_PageResource')) { - \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + static function () use ($postId, $scope): void { + if (! class_exists('ET_Core_PageResource')) { + return; + } + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + // A global library entry is part of every page that shows it (spec §5.6 step 5). + if ($scope->clearsSiteCss()) { + \ET_Core_PageResource::remove_static_resources('all', 'all', true); + if (function_exists('et_theme_builder_clear_wp_cache')) { + et_theme_builder_clear_wp_cache('all'); + } } }, 'divi static css', @@ -118,24 +132,27 @@ class DiviSaver } /** - * The first content of a brand-new draft (lit/content-create with divi, which inserted the - * draft with its normal WordPress fields – title and excerpt went through kses as usual). - * Same kses window as save(), holding only this wp_update_post() and its hooks; no restore - * point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part - * of the write here: without them the new entry is no Divi layout. Every failure throws – - * the caller deletes the draft (all or nothing). + * The first content of a brand-new post: a draft of lit/content-create with divi or a library + * entry of lit/library-save, both inserted with their normal WordPress fields first (title and + * excerpt went through kses as usual). Same kses window as save(), holding only this + * wp_update_post() and its hooks; no restore point (the post has no earlier state) and no + * static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them + * a new page is no Divi layout); SaveScope::LibraryItem sets none. Every failure throws – the + * caller deletes the new post (all or nothing). * * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException */ - public function saveNew(int $postId, string $content): array + public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); } - DiviFlags::set($postId); + if ($scope->setsFlags()) { + DiviFlags::set($postId); + } $stored = get_post($postId); diff --git a/src/Integrations/Divi/Pages/DiviSchema.php b/src/Integrations/Divi/Pages/DiviSchema.php index 8d4913e..bf6f97c 100644 --- a/src/Integrations/Divi/Pages/DiviSchema.php +++ b/src/Integrations/Divi/Pages/DiviSchema.php @@ -18,6 +18,11 @@ final class DiviSchema 'type' => 'string', 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', ], + 'confirm' => [ + 'type' => 'boolean', + 'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer ' + . 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.', + ], ]; } diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 5a90b13..3bddc36 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -14,8 +14,8 @@ defined('ABSPATH') || exit; * The frame of the Divi write tools (spec §5.6): permission and execute both load the page * through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state * from permission), the tool changes the tree in change() – only when its whole input - * validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the - * result. The composition is bound to the page (DiviWriter::composition(), ruling S22). + * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries: + * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22). */ abstract class DiviWriteAbility implements Ability { @@ -33,7 +33,15 @@ abstract class DiviWriteAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + return ToolResult::schema([ + 'tree_rev' => ['type' => 'string'], + // Global library entries (answer F2): the pages that show the entry, and the backup + // made when revisions are off. + 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], + 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], + 'backup_id' => ['type' => 'integer'], + ], ['tree_rev']); } public function isDirect(): bool @@ -43,6 +51,11 @@ abstract class DiviWriteAbility implements Ability final public function permission(array $input): bool|WP_Error { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); return $page instanceof WP_Error ? $page : true; @@ -50,6 +63,11 @@ abstract class DiviWriteAbility implements Ability final public function execute(array $input): mixed { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); if ($page instanceof WP_Error) { return $page; @@ -57,9 +75,20 @@ abstract class DiviWriteAbility implements Ability $change = $this->change($page, $input, $this->writer->composition($page)); if ($change instanceof WP_Error) { - return $change; + return $this->writer->notice($page, $change); } - return $this->writer->save($this->name(), $page, $change, $input); + return $this->writer->commit($this->name(), $page, $change, $input); + } + + /** + * Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in + * permission() and again in execute(). Null = none. + * + * @param array $input + */ + protected function precheck(array $input): ?WP_Error + { + return null; } } diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 42a1e11..eeeecfa 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use WP_Error; @@ -23,7 +24,8 @@ defined('ABSPATH') || exit; * (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, - * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. + * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point + * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup). */ final class DiviWriter { @@ -35,7 +37,8 @@ final class DiviWriter private DiviSaver $saver, private ChangeLog $log, private DesignSystemReader $design, - private DiviComposition $composition + private DiviComposition $composition, + private ?GlobalEdits $globals = null ) { } @@ -79,8 +82,13 @@ final class DiviWriter * @return array * @throws WriteFailed */ - public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array - { + public function save( + string $ability, + DiviPage $page, + DiviChange $change, + array $input, + SaveScope $scope = SaveScope::Page + ): array { $post = $page->post; $content = Serializer::document($page->document); if ($content === (string) $post->post_content) { @@ -97,7 +105,7 @@ final class DiviWriter $warnings = array_merge($change->warnings(), $quality); try { - $saved = $this->saver->save($post, $content); + $saved = $this->saver->save($post, $content, $scope); } catch (WriteFailed $e) { if (! $e->rolledBack) { $this->log->recordPartial( @@ -140,6 +148,82 @@ final class DiviWriter return $tool->toArray(); } + /** + * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one + * needs confirm (dry run first), gets a backup when revisions are off and clears every page's + * cache; the result then names the affected pages. + * + * @param array $input + * @return array|WP_Error + * @throws WriteFailed + */ + public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error + { + if (! $page->isLibraryItem()) { + return $this->save($ability, $page, $change, $input); + } + + if ($this->globals === null) { + return Errors::unsupported( + 'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.', + 'Den Eintrag im Divi-Builder bearbeiten.' + ); + } + + if (Serializer::document($page->document) === (string) $page->post->post_content) { + return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem); + } + + $prepared = $this->globals->prepare($page, $input); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + try { + $saved = $this->save($ability, $page, $change, $input, $prepared['scope']); + } catch (WriteFailed $e) { + throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null); + } + + return array_merge($saved, $prepared['details']); + } + + /** + * Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names + * it, because it stays in the trash and is the only earlier state. + */ + private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed + { + if (! is_int($backupId)) { + return $e; + } + + $kept = sprintf( + 'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress ' + . 'wiederherstellbar).', + $backupId + ); + if ($e->rolledBack) { + return $e->withDetails( + ['backup_id' => $backupId], + $kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.', + Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId)) + ); + } + + return $e->withDetails( + ['backup_id' => $backupId], + 'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und ' + . 'dem Nutzer Bescheid geben.' + ); + } + + /** A tool's confirm_required on a global library entry, extended by the affected pages. */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error; + } + /** * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * node has no block any more, but its ID from before the removal still names the (surviving) diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 10f0c6a..e2457e8 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -31,7 +31,8 @@ defined('ABSPATH') || exit; */ class DynamicContentMap { - private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; + /** Divi's dynamic-content registry class; also checked by DiviCompatibility. */ + public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; /** * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. @@ -42,6 +43,51 @@ class DynamicContentMap /** @return array>|null Null = use DynamicContentRules' default map. */ public function forPost(int $postId): ?array + { + $raw = $this->raw($postId); + + return $raw === null ? null : self::fromOptions($raw); + } + + /** + * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same + * names and types as forPost(), loop_* left out). custom_meta_ options a filter registers + * for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so + * the listing never names them. Only label, group and setting types leave Divi's registry – + * no select options or defaults (they list meta keys). Null when the registry is unavailable. + * + * @return array}>|null + */ + public function options(int $postId): ?array + { + $raw = $this->raw($postId); + if ($raw === null) { + return null; + } + + $options = []; + foreach (self::fromOptions($raw) as $name => $settings) { + if (str_starts_with($name, 'custom_meta_') && ! DynamicContentRules::isMetaKey($name)) { + continue; + } + + $option = is_array($raw[$name] ?? null) ? $raw[$name] : []; + $options[$name] = [ + 'label' => is_string($option['label'] ?? null) ? $option['label'] : $name, + 'group' => is_string($option['group'] ?? null) ? $option['group'] : '', + 'settings' => $settings, + ]; + } + + return $options; + } + + /** + * Divi's registry for the post; any filter error is logged (message only) and yields null. + * + * @return array|null + */ + private function raw(int $postId): ?array { try { $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); @@ -51,7 +97,7 @@ class DynamicContentMap return null; } - return is_array($options) && $options !== [] ? self::fromOptions($options) : null; + return is_array($options) && $options !== [] ? $options : null; } /** diff --git a/src/Integrations/Divi/Pages/SaveScope.php b/src/Integrations/Divi/Pages/SaveScope.php new file mode 100644 index 0000000..8841307 --- /dev/null +++ b/src/Integrations/Divi/Pages/SaveScope.php @@ -0,0 +1,33 @@ +}} targets from WordPress: the entry exists, lives (no trash, auto-draft or + * revision) and the current user may read it (read_post, like select_post settings, S37). + * Missing and unreadable entries give the same null (no existence oracle). + */ +final class WpLinkTargets implements LinkTargets +{ + private const DEAD = ['trash', 'auto-draft', 'inherit']; + + public function postTypeOf(int $id): ?string + { + $post = get_post($id); + if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) { + return null; + } + + return current_user_can('read_post', $id) ? (string) $post->post_type : null; + } +} diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php index 317a69a..e00987c 100644 --- a/src/Integrations/WordPress/Content/NewDraft.php +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -22,7 +22,8 @@ defined('ABSPATH') || exit; * returned like any invalid input; partial write if the delete fails. * - discard(): writing after the insert failed – created terms and the draft are deleted; * rolled back, or partial write if that cleanup failed. - * Partial writes are logged with result "partial". + * Partial writes are logged with result "partial". lit/library-save reuses it for a new library + * entry (ruling P6): no PostWriter (no terms of its own to clean up), $noun names the entry. */ final class NewDraft { @@ -30,11 +31,13 @@ final class NewDraft public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.'; public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.'; + /** @param string $noun What the new post is called in change-log summaries. */ public function __construct( - private PostWriter $writer, + private ?PostWriter $writer, private ChangeLog $log, private string $ability, - private string $postType + private string $postType, + private string $noun = 'Entwurf' ) { } @@ -50,7 +53,7 @@ final class NewDraft } catch (Throwable $e) { // A hook threw – the row may or may not exist and its ID is unknown; nothing is // searched or deleted, the client is told to check (lit_partial_write without id). - $this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); + $this->logPartial('', sprintf('Anlegen abgebrochen; %s möglicherweise vorhanden', $this->noun)); throw WriteFailed::partial(self::REASON_INSERT, $e); } if ($postId instanceof WP_Error) { @@ -77,7 +80,7 @@ final class NewDraft } if (! $deleted) { - $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); + $summary = sprintf('%s %d: Anlegen nicht zurückgenommen', $this->noun, $postId); $this->logPartial((string) $postId, $summary); $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; throw WriteFailed::partial( @@ -102,7 +105,7 @@ final class NewDraft public function discard(int $postId, Throwable $cause, string $reason): never { $problems = Rollback::attempt( - fn () => $this->writer->deleteCreatedTerms(), + fn () => $this->writer?->deleteCreatedTerms(), static fn () => self::deleteDraft($postId) ); @@ -111,7 +114,7 @@ final class NewDraft throw WriteFailed::rolledBack($reason, $cause, $detail); } - $summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); + $summary = sprintf('%s %d: Anlegen nur teilweise zurückgenommen', $this->noun, $postId); $this->logPartial((string) $postId, $summary); throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); } diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index cf88bf4..1cc8be4 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -45,7 +45,7 @@ final class PostGuard $post = get_post($id); if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { - return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + return self::notFound($id); } if (! current_user_can('edit_post', $id)) { @@ -63,6 +63,12 @@ final class PostGuard return $post; } + /** The refusal for an ID that names no entry the tools manage – the same for every such ID. */ + public static function notFound(int $id): WP_Error + { + return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + } + /** * A scheduled entry whose publication time has passed (WordPress' cron missed it) or is * less than a minute away: wp_insert_post() turns "future" into "publish" on any save when diff --git a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php index 67be670..b111c95 100644 --- a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -19,6 +19,9 @@ defined('ABSPATH') || exit; */ final class PageRenderSummaryAbility implements Ability { + /** At most this many global-module notes per summary. */ + private const GLOBAL_NOTES = 20; + public function __construct(private ContentSchema $schema, private PageRenderer $renderer) { } @@ -40,7 +43,8 @@ final class PageRenderSummaryAbility implements Ability . 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Das Rendern führt die ' . 'üblichen Inhalts-Filter aus (Shortcodes, Divi, andere Plugins) und kann dabei Netzwerkanfragen ' . 'auslösen, z. B. oEmbed-Abrufe, die von WordPress oder anderen Plugins zwischengespeichert werden. ' - . 'Gut zum Prüfen nach Änderungen.'; + . 'Gut zum Prüfen nach Änderungen. Globale Module (Divi-Bibliothek) fehlen im Auszug und werden ' + . 'unter notes genannt.'; } public function inputSchema(): array @@ -104,10 +108,32 @@ final class PageRenderSummaryAbility implements Ability $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; } + foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) { + $notes[] = sprintf( + 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' + . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', + $id, + $id + ); + } + return array_merge( ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], RenderSummary::fromHtml($this->renderer->render($post)), ['notes' => $notes] ); } + + /** + * IDs of the global modules in $content, found the way Divi finds them (a string scan for + * "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there). + * + * @return list + */ + private static function globalModuleIds(string $content): array + { + preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches); + + return array_values(array_unique($matches[1])); + } } diff --git a/src/Plugin.php b/src/Plugin.php index daef929..4b4c5d8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; @@ -43,7 +44,7 @@ defined('ABSPATH') || exit; */ final class Plugin { - public const VERSION = '0.3.0'; + public const VERSION = '0.4.0'; private static string $pluginFile = ''; private static ?SettingsPage $settingsPage = null; @@ -71,9 +72,12 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); - $divi = new DiviIntegration($schema, $changeLog); $registry = null; $policy = null; + // Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries. + $libraryVisible = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP); + }; // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // when registry and policy exist), so the Divi group's switches stop every Divi layout write. $diviWritable = static function () use (&$registry, &$policy): bool { @@ -81,11 +85,30 @@ final class Plugin && $policy instanceof ExposurePolicy && $policy->allows('lit/divi-insert', $registry->abilities()); }; + // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9). + $libraryWritable = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); + }; + // Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state. + $libraryInsertable = static function () use (&$registry, &$policy): bool { + return $registry instanceof IntegrationRegistry + && $policy instanceof ExposurePolicy + && $policy->allows('lit/library-insert', $registry->abilities()); + }; + $divi = new DiviIntegration( + $schema, + $changeLog, + $libraryWritable, + libraryVisible: $libraryVisible, + diviWritable: $diviWritable, + libraryInsertable: $libraryInsertable + ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), - $divi + $divi, + $divi->library() ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Fixtures/divi/stage3/README.md b/tests/Fixtures/divi/stage3/README.md new file mode 100644 index 0000000..e81f8f1 --- /dev/null +++ b/tests/Fixtures/divi/stage3/README.md @@ -0,0 +1,115 @@ +# Divi-5-Fixtures für Etappe 3 (Library, globale Module, Theme Builder) + +Echte Daten aus Divi 5.2.1, erzeugt am 2026-09-26 auf der lokalen DDEV-Site (`https://wordpress.ddev.site:8443`). Alle Einträge wurden über **Divis eigene Speicherwege** angelegt – den echten Visual Builder und die echte Theme-Builder-Oberfläche in einem headless Chrome (Chrome 153, per DevTools-Protokoll gesteuert, angemeldet als Administrator). Weder Plugin-Code noch eigene Schreibwege in die Datenbank waren beteiligt; einzige Ausnahme ist `et_builder_enable_for_post()` für die neue Seite (siehe 1). + +Pfade zu Divi relativ zu `wp-content/themes/Divi/includes/`. Die Builder-JS-Dateien liegen in `builder-5/visual-builder/build/` und sind minifiziert – dort steht statt einer Zeilennummer ein Suchbegriff. + +## Dateien + +Je Post eine `.json` (Post-Zeile, **alle** Meta-Einträge roh in Speicherreihenfolge, alle Term-Beziehungen direkt aus den Tabellen) und, falls Inhalt vorhanden, `.html` mit `post_content` byte-genau. `manifest.json` ordnet Rolle → Post-ID zu, `theme-builder.json` enthält den Live-Theme-Builder-Post mit seiner Vorlagenliste, die `et_tb_*`-Optionen und die als ungenutzt markierten Posts. + +| Rolle | ID | Typ | Inhalt | +|---|---|---|---| +| `page--globale-instanzen` | 1053 | `page` (Entwurf) | eine Instanz des globalen Abschnitts, eine Instanz des globalen Moduls, normale Abschnitte | +| `library--layout-ganze-seite` | 1054 | `et_pb_layout` | ganzes Seitenlayout (vor dem Globalmachen gespeichert), Kategorie „Fixture“ | +| `library--library-abschnitt` | 1055 | `et_pb_layout` | Abschnitt, nicht global | +| `library--library-zeile` | 1056 | `et_pb_layout` | Zeile, nicht global | +| `library--library-modul` | 1057 | `et_pb_layout` | Blurb-Modul, nicht global, Schlagwort „fixture“ | +| `library-global--globaler-abschnitt` | 1058 | `et_pb_layout` | globaler Abschnitt | +| `library-global--globales-modul` | 1059 | `et_pb_layout` | globales Text-Modul | +| `tb-header--globaler-header` | 1061 | `et_header_layout` | globaler Header (Header der Standardvorlage) | +| `tb-body--body-mit-dynamic-content` | 1062 | `et_body_layout` | Dynamic Content: Titel, Meta-Feld, Auszug, Modul „Beitragsinhalt“ | +| `tb-footer--footer` | 1063 | `et_footer_layout` | Footer | +| `tb-template--default` | 1065 | `et_template` | „Standard-Website-Template“, **deaktiviert**, Header = 1061 | +| `tb-template--vorlage-globale-instanzen` | 1066 | `et_template` | **deaktiviert**, Header 1061 / Body 1062 / Footer 1063, Bedingung `singular:post_type:page:id:1053` | +| (theme-builder.json) | 1064 | `et_theme_builder` | Live-TB-Post, `_et_template` = [1065, 1066] | + +Nicht exportiert: die Revision 1060 der Seite. + +## Wie die Fixtures entstanden sind + +### 1. Seite „[Fixture] Globale Instanzen“ (1053) + +- Angelegt als leerer Entwurf (`wp post create`), dann `et_builder_enable_for_post(1053)` (`builder/core.php:4438-4452`) – das tut der Button „Divi Builder verwenden“; ohne `_et_pb_use_builder` öffnet `et_fb=1` den Builder nicht (`et_fb_is_enabled`, `builder/core.php:4195-4273`). +- Visual Builder geöffnet (`?page_id=1053&et_fb=1`). Die App läuft im iframe `app_window=1`; dort `divi.data`. +- Inhalt in den Builder-Store gesetzt: `dispatch('divi/edit-post').setContent(flat)` (Aktion `SET_CONTENT`, `edit-post.js`, Suche `Ht=e=>({type:"SET_CONTENT"`). Die Modul-Attribute (Admin-Labels, Texte, `builderVersion 5.2.1`, Spalte `flexType 24_24`) haben **wir** gewählt – wie bei den Etappe-2-Seiten; es wurden keine Module per Klick eingefügt, Divis Standardattribute beim Einfügen fehlen also. +- Gespeichert wie der Button „Entwurf speichern“: `dispatch('divi/serialized-post').syncToServer({syncType:'draft'})` (`app-ui.js`, Suche `syncToServer({syncType:"draft"`) → Divis Serialisierer (`serialized-post.js`, Suche `k=(t,e="root",r="global-module")`) → `POST divi/v1/sync-to-server` (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:819-826`, `SyncToServerController::update`, `builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php:56`). + +### 2. Library-Einträge (1054–1059) + +Über den echten Dialog „Zur Bibliothek hinzufügen“: `dispatch('divi/modal-library').open({name:'divi/add-to-library', owner:})` (so öffnet ihn das Rechtsklickmenü, `right-click-options.js`, Suche `name:"divi/add-to-library",owner:e`; für das ganze Layout `owner:""` wie das Tastenkürzel in `root.js`). Name, neue Kategorie/Schlagwort und der Schalter „Als Global abspeichern“ wurden im Dialog-DOM gesetzt, dann „Zur Bibliothek hinzufügen“ geklickt. Damit lief Divis eigener `onSaveClick` (`modal-library.js`, Suche `onSaveClick:({moduleNameOption`): + +- Serialisierung `serializeModuleFlatObjects` (ganzes Layout mit Kontext `"layout"`), FormData mit `layout_type`, `layout_scope`, `module_type`, `module_width`, `columns_layout`, `post_type`; +- `POST divi/v1/divi-library/create-item` (`RESTRegistration.php:743-749`, `DiviLibraryController::create_item`, `builder-5/server/VisualBuilder/REST/DiviLibrary/DiviLibraryController.php:606-757`) → `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) → `et_pb_create_layout()` (`:738-800`); +- bei „global“ danach im Builder `Tn` (`modal-library.js`, Suche `Tn=({moduleId:e,templateId:t`): `setGlobalModuleAttribute(, )`, `setGlobalParentAttribute` für alle Nachkommen (`Nn`), `global-layouts.setLayoutContent`. Anschließend wurde die Seite gespeichert (siehe 1) – so entstanden die Instanzen in 1053. + +Reihenfolge: Layout 1054, Abschnitt 1055, Zeile 1056, Modul 1057, globaler Abschnitt 1058, globales Modul 1059, dann Seite speichern. + +### 3. Theme Builder (1061–1066) + +Auf der Seite `wp-admin/admin.php?page=et_theme_builder` mit den dort lokalisierten Nonces (`et_theme_builder_bundle.config.nonces`, `wp_localize_script` in `builder/frontend-builder/theme-builder/admin.php:129`) und jQuery, wie die TB-App (`builder/frontend-builder/build/theme-builder.js`, Suche `createLayout:function(e){return(0,l.post)` und `save:function(e,t)`): + +1. `et_theme_builder_api_create_layout` für `header`, `body`, `footer` (`theme-builder/api.php:12-46` → `et_theme_builder_insert_layout`, `theme-builder/theme-builder.php:1643-1667`) → 1061, 1062, 1063. +2. `et_theme_builder_api_save` mit `live=1` (`api.php:160-273` → `et_theme_builder_store_template`, `theme-builder.php:1463-1586`) mit zwei Vorlagen: Standardvorlage (Header 1061, **`enabled=0`**) und „[Fixture] Vorlage Globale Instanzen“ (Header 1061, Body 1062, Footer 1063, **`enabled=0`**, `use_on[]=singular:post_type:page:id:1053`). Divi hat dabei den Live-TB-Post 1064 angelegt (`et_theme_builder_get_theme_builder_post_id`, `theme-builder.php:378`), die Vorlagen 1065/1066 mit `publish` gespeichert und `_et_template` gesetzt. +3. `et_theme_builder_api_get_layout_url` (`api.php:116-147`) für die Builder-URLs; jede Layout-URL **ohne** `app_window=1` im Visual Builder geöffnet, Inhalt per `setContent` gesetzt, Titel „[Fixture] …“ über das Feld „Titel“ der Seiteneinstellungen eingetragen und mit `syncToServer({syncType:'publish'})` gespeichert (TB-Layouts sind immer `publish`) → `divi/v1/sync-to-server` wie oben. + +„Globaler Header“ heißt im Theme Builder: Header-Layout der Standardvorlage, das andere Vorlagen mit derselben ID mitbenutzen (1065 und 1066 zeigen beide auf 1061). Die Meta `_et_{header,body,footer}_layout_global` gibt es nur für TB-Library-Einträge, nicht für die Live-Vorlagen. + +### 4. Dynamic Content (Body-Layout 1062) + +Im Body-Layout: Überschrift = `post_title`, Text = `post_meta_key` mit manuellem Schlüssel `fixture_untertitel`, Text = `post_excerpt`, dazu das Modul `divi/post-content`. Die Werte haben die Form `$variable({"type":"content","value":{"name":"…","settings":{…}}})$` (`module.js`, Suche ``$variable(${JSON.stringify(s)})$``; Server: `builder-5/server/Packages/Module/Layout/Components/DynamicContent/`). + +## Was echt ist und was nachgebildet wurde + +**Echt aus Divi (JS-Serialisierer + Server):** gesamte Blockstruktur und Serialisierung aller `.html`, der `divi/placeholder`-Rahmen, die Form der globalen Instanzen, alle Library-Terme und -Meta, Post-Zeilen, alle Meta der Seite und der TB-Layouts, TB-Vorlagen-Meta, `_et_template` am TB-Post, der Umgang mit Kategorie/Schlagwort-Neuanlage. + +**Von uns vorgegeben bzw. aus JS nachgebildet:** + +- Die Modul-**Attribute** im Builder-Store (siehe 1). Divi hat sie unverändert serialisiert, aber nicht selbst erzeugt. +- Die Vorlagen-Nutzdaten für `et_theme_builder_api_save`: nachgebaut nach `formatTemplatesForStorage` (`theme-builder.js`, Suche `formatTemplatesForStorage=function`): `{id:, title, autogenerated_title:"0", default:"1"/"0", enabled:"1"/"0", layouts:{header|body|footer:{id, enabled:"1"}}, use_on:[…], exclude_from:[]}`, `hasDefault:"0"`, `first_request`/`last_request:"1"`. Der Server-Teil (Speichern, Zuordnung) ist echt. +- Die `settings` der Dynamic-Content-Werte: aus den `default`-Werten der Felder in `settings.dynamicContent.options` (so füllt der Einstellungsdialog vor) plus `meta_key`. Im echten Dialog können Schlüsselreihenfolge und ausgeblendete Felder (`custom_date_format`, `date_format` ohne Datumsfeld) abweichen. Die Namen (`post_title`, `post_meta_key`, `post_excerpt`) und die `$variable(…)$`-Hülle sind aus Divi belegt. + +## Format-Befunde (für Etappe 3 wichtig) + +1. **Der Visual Builder speichert anders als unsere bisherigen Seiten:** Zeilenumbrüche zwischen Blöcken, Module ohne Kinder **selbstschließend** (``, danach eine Leerzeile), `builderVersion` steht am Ende der Attribute; der `divi/placeholder`-Rahmen ohne Umbruch. Divis D4-Konvertierung und das Plugin schreiben dagegen gepaarte Kommentare ohne Umbrüche. Beide Formen kommen auf echten Seiten vor. Der Plugin-Parser liest alle zehn `.html` hier byte-identisch zurück (Parse → Serialize). +2. **Globale Instanz auf der Seite:** ``, darin die Kinder mit ihren **echten** Blocknamen und jeweils `"globalParent":"1058"`. Ein globales Modul ohne Kinder ist ein selbstschließender `divi/global-layout`-Block. `localAttrs` ist ein vollständiger Schnappschuss (kein leeres Objekt); `localAttrsMap`/`localChildren` fehlen, solange nichts entkoppelt ist. +3. **Globaler Library-Post** enthält normalen Inhalt ohne `globalModule`/`globalParent`; „global“ steckt nur im Term `scope=global` (beim Modul zusätzlich `_et_pb_module_type`, `_et_pb_excluded_global_options = "[]"`). +4. Library-Einträge bekommen **kein** `_et_pb_use_divi_5` und kein `_et_pb_use_builder` (bestätigt F3). Seiten und TB-Layouts nach VB-Speichern schon. +5. **Zeilen-Bug in Divi:** `_et_pb_row_layout` ist `"[object Object]"` (der Builder schickt `columns_layout` als stringifiziertes Objekt). Nicht als Wert verwenden. +6. **TB-Layouts haben keinen `layout_type`-Term**, obwohl `et_theme_builder_insert_layout` ihn setzen will (`theme-builder.php:1663`). In `wp_term_relationships` steht für 1061–1063 nichts; vermutlich ist die Taxonomie im admin-ajax-Aufruf nicht registriert (Registrierung über `ET_Builder_Library`, `builder/feature/Library.php:397`) – die Ursache ist nicht weiter geprüft. Nicht auf Terme bei TB-Layouts verlassen. +7. **TB-Bedingungen** stehen als mehrfaches Meta `_et_use_on` / `_et_exclude_from` mit IDs wie `singular:post_type:page:id:1053`. Die Vorlagenzuordnung ist `_et_template` (mehrfach) am `et_theme_builder`-Post; Vorlagen-Titel der Standardvorlage ist übersetzt („Standard-Website-Template“). +8. TB-Layouts heißen nach dem Anlegen alle „Theme Builder Layout“ mit Slug `theme-builder-layout` (in jedem Post-Typ gleich); erst der Titel im Builder benennt sie um. `_et_pb_post_settings_title` spiegelt den Titel. +9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`. + +## Wirkung auf die Live-Site + +Vorher gab es keinen Theme-Builder-Post und keine Vorlagen. Jetzt existiert der Live-TB-Post 1064. Beide Vorlagen sind deaktiviert: `ET_Theme_Builder_Request::get_template()` überspringt deaktivierte Vorlagen (`theme-builder/ThemeBuilderRequest.php:393`) und nimmt die Standardvorlage nur, wenn sie aktiv ist (`:434`). Die Fixture-Vorlage wäre zudem nur für die Entwurfsseite 1053 zuständig. Geprüft: `get_template()` liefert für 1053 nichts, die Startseite enthält keinen Fixture-Header. Beim Live-Speichern hat Divi `et_theme_builder_trash_draft_and_unused_posts()` (`theme-builder.php:591`) ausgeführt; nichts wurde als ungenutzt markiert. **Achtung:** Wer im Theme Builder später speichert, speichert die komplette Vorlagenliste neu; Layouts, die dann nicht mehr zugeordnet sind, markiert Divi und löscht sie nach 7 Tagen. + +Neu angelegte Terme (vorher gab es keine Library-Terme): `layout_category` „Fixture“ (26), `layout_tag` „fixture“ (32) sowie Divis Systemterme `scope` (27, 34), `layout_type` (28, 30, 31, 33), `module_width` (29). + +## Neu exportieren + +``` +ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php +``` + +Exportiert werden nur Posts mit Titel „[Fixture] …“ und die Standardvorlage, wenn sie auf Fixture-Layouts zeigt. Das Skript löscht vorher alle `*.json`/`*.html` in diesem Ordner. + +## Aufräumen (lokal) + +Die Daten bleiben vorerst zum Ansehen stehen. Zum Entfernen (DDEV, WordPress-Wurzel; IDs vorher mit `manifest.json` abgleichen): + +``` +# Theme Builder: TB-Post (von den Fixtures neu angelegt), Vorlagen, Layouts +ddev wp post delete 1064 1065 1066 1061 1062 1063 --force +# Library-Einträge und Seite (Revision 1060 fällt mit weg) +ddev wp post delete 1054 1055 1056 1057 1058 1059 1053 --force +# Von den Fixtures angelegte Terme +ddev wp term delete layout_category 26 +ddev wp term delete layout_tag 32 +ddev wp eval 'foreach ([27,28,29,30,31,33,34] as $t) { wp_delete_term($t, get_term($t)->taxonomy); }' +# Divi-Caches +ddev wp eval 'et_theme_builder_clear_wp_cache("all"); ET_Core_PageResource::remove_static_resources("all", "all");' +``` + +`scope`, `layout_type` und `module_width` legt Divi beim nächsten Speichern in die Library wieder an. Gab es bis dahin eigene Library-Einträge, die diese Terme benutzen, die dritte Zeile weglassen. diff --git a/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html new file mode 100644 index 0000000..705efb4 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json new file mode 100644 index 0000000..c995a88 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json @@ -0,0 +1,51 @@ +{ + "post": { + "ID": 1054, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Layout ganze Seite", + "post_name": "fixture-layout-ganze-seite", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:10", + "post_modified_gmt": "2026-09-26 20:56:10", + "content_file": "library--layout-ganze-seite.html", + "content_sha256": "2fcb9fb909d698b97581531ac69c5a3973647a57a4970d7a45a51fbe4ae24968" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "layout" + ] + }, + "terms": { + "layout_category": [ + { + "slug": "fixture", + "name": "Fixture" + } + ], + "layout_type": [ + { + "slug": "layout", + "name": "layout" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-abschnitt.html b/tests/Fixtures/divi/stage3/library--library-abschnitt.html new file mode 100644 index 0000000..3ad44eb --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-abschnitt.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-abschnitt.json b/tests/Fixtures/divi/stage3/library--library-abschnitt.json new file mode 100644 index 0000000..42539d3 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-abschnitt.json @@ -0,0 +1,45 @@ +{ + "post": { + "ID": 1055, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Abschnitt", + "post_name": "fixture-library-abschnitt", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:14", + "post_modified_gmt": "2026-09-26 20:56:14", + "content_file": "library--library-abschnitt.html", + "content_sha256": "c26526fccd9a1dac416a91ddd1459059858783478921744698b77fe83dcb7e94" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "section" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "section", + "name": "section" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-modul.html b/tests/Fixtures/divi/stage3/library--library-modul.html new file mode 100644 index 0000000..a627cf5 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-modul.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-modul.json b/tests/Fixtures/divi/stage3/library--library-modul.json new file mode 100644 index 0000000..7c51c2b --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-modul.json @@ -0,0 +1,54 @@ +{ + "post": { + "ID": 1057, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Modul", + "post_name": "fixture-library-modul", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:21", + "post_modified_gmt": "2026-09-26 20:56:21", + "content_file": "library--library-modul.html", + "content_sha256": "fd36cf6168b7492d26601fb4ed11accecc30fe1f8b7ec5a4d4091fc40d210ba4" + }, + "meta": { + "_et_pb_module_type": [ + "divi/blurb" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "module" + ] + }, + "terms": { + "layout_tag": [ + { + "slug": "fixture", + "name": "fixture" + } + ], + "layout_type": [ + { + "slug": "module", + "name": "module" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-zeile.html b/tests/Fixtures/divi/stage3/library--library-zeile.html new file mode 100644 index 0000000..34a05dc --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-zeile.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-zeile.json b/tests/Fixtures/divi/stage3/library--library-zeile.json new file mode 100644 index 0000000..2ef95b8 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-zeile.json @@ -0,0 +1,48 @@ +{ + "post": { + "ID": 1056, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Zeile", + "post_name": "fixture-library-zeile", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:18", + "post_modified_gmt": "2026-09-26 20:56:18", + "content_file": "library--library-zeile.html", + "content_sha256": "0a47d5133a4ca10a5fd0ebb49908894a5c20a17798d5954e0897958ad4704dfe" + }, + "meta": { + "_et_pb_row_layout": [ + "[object Object]" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "row" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "row", + "name": "row" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html new file mode 100644 index 0000000..e9c5312 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json new file mode 100644 index 0000000..0419e1f --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json @@ -0,0 +1,45 @@ +{ + "post": { + "ID": 1058, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Globaler Abschnitt", + "post_name": "fixture-globaler-abschnitt", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:25", + "post_modified_gmt": "2026-09-26 20:56:25", + "content_file": "library-global--globaler-abschnitt.html", + "content_sha256": "dd1549b879730de4d937c84236bb8e548f50024e47e9910561f24ae0e91b13a6" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "section" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "section", + "name": "section" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "global", + "name": "global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library-global--globales-modul.html b/tests/Fixtures/divi/stage3/library-global--globales-modul.html new file mode 100644 index 0000000..d6acb90 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globales-modul.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library-global--globales-modul.json b/tests/Fixtures/divi/stage3/library-global--globales-modul.json new file mode 100644 index 0000000..8655bf7 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globales-modul.json @@ -0,0 +1,51 @@ +{ + "post": { + "ID": 1059, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Globales Modul", + "post_name": "fixture-globales-modul", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:29", + "post_modified_gmt": "2026-09-26 20:56:29", + "content_file": "library-global--globales-modul.html", + "content_sha256": "3fe0aa7eb9a0aad0e79cc23702c3082f7ff8fbbb80e35185f01ccfc751c1690b" + }, + "meta": { + "_et_pb_module_type": [ + "divi/text" + ], + "_et_pb_excluded_global_options": [ + "[]" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "module" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "module", + "name": "module" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "global", + "name": "global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/manifest.json b/tests/Fixtures/divi/stage3/manifest.json new file mode 100644 index 0000000..cb38cc1 --- /dev/null +++ b/tests/Fixtures/divi/stage3/manifest.json @@ -0,0 +1,17 @@ +{ + "divi_version": "5.2.1", + "posts": { + "page--globale-instanzen": 1053, + "library--layout-ganze-seite": 1054, + "library--library-abschnitt": 1055, + "library--library-zeile": 1056, + "library--library-modul": 1057, + "library-global--globaler-abschnitt": 1058, + "library-global--globales-modul": 1059, + "tb-header--globaler-header": 1061, + "tb-body--body-mit-dynamic-content": 1062, + "tb-footer--footer": 1063, + "tb-template--vorlage-globale-instanzen": 1066, + "tb-template--default": 1065 + } +} diff --git a/tests/Fixtures/divi/stage3/page--globale-instanzen.html b/tests/Fixtures/divi/stage3/page--globale-instanzen.html new file mode 100644 index 0000000..9e32819 --- /dev/null +++ b/tests/Fixtures/divi/stage3/page--globale-instanzen.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/page--globale-instanzen.json b/tests/Fixtures/divi/stage3/page--globale-instanzen.json new file mode 100644 index 0000000..9e94d99 --- /dev/null +++ b/tests/Fixtures/divi/stage3/page--globale-instanzen.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1053, + "post_type": "page", + "post_status": "draft", + "post_title": "[Fixture] Globale Instanzen", + "post_name": "", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "0000-00-00 00:00:00", + "post_modified_gmt": "2026-09-26 20:56:33", + "content_file": "page--globale-instanzen.html", + "content_sha256": "e5691e17e77315c449c5bf536cb3944ea0bc0bee316c45aa26e3b04485253623" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1053\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Globale Instanzen" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"4d4f306d-8e36-4b8f-9a7a-076eb1b075ac\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html new file mode 100644 index 0000000..a9ae55d --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json new file mode 100644 index 0000000..b1f4afe --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1062, + "post_type": "et_body_layout", + "post_status": "publish", + "post_title": "[Fixture] Body mit Dynamic Content", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 21:00:07", + "content_file": "tb-body--body-mit-dynamic-content.html", + "content_sha256": "4f2830538ea84c8c490ee278be883f09d89816c9635c7571be294c8bdadce3a2" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1062\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Body mit Dynamic Content" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"b773e1de-72ba-4640-85d6-249ce9e4a82d\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-footer--footer.html b/tests/Fixtures/divi/stage3/tb-footer--footer.html new file mode 100644 index 0000000..dcd03c9 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-footer--footer.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-footer--footer.json b/tests/Fixtures/divi/stage3/tb-footer--footer.json new file mode 100644 index 0000000..5403923 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-footer--footer.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1063, + "post_type": "et_footer_layout", + "post_status": "publish", + "post_title": "[Fixture] Footer", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 21:00:21", + "content_file": "tb-footer--footer.html", + "content_sha256": "7e6f6744cbc55a903cf346328ab383e7ddda85292990cf93b49594fa6f3d7f53" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1063\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Footer" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"7f8e1cce-7f40-4ac6-bc6d-239e0050ed4a\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-header--globaler-header.html b/tests/Fixtures/divi/stage3/tb-header--globaler-header.html new file mode 100644 index 0000000..d93ffb6 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-header--globaler-header.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-header--globaler-header.json b/tests/Fixtures/divi/stage3/tb-header--globaler-header.json new file mode 100644 index 0000000..e16320b --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-header--globaler-header.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1061, + "post_type": "et_header_layout", + "post_status": "publish", + "post_title": "[Fixture] Globaler Header", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:33", + "post_modified_gmt": "2026-09-26 20:59:52", + "content_file": "tb-header--globaler-header.html", + "content_sha256": "aa7c71a22cd8d7b3ecb7f6fbcbeaff289322b3797db3f3aac87763d29bb4b851" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1061\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Globaler Header" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"b20ac30f-aeb9-4b8a-863f-44d36b514202\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-template--default.json b/tests/Fixtures/divi/stage3/tb-template--default.json new file mode 100644 index 0000000..cc5e986 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-template--default.json @@ -0,0 +1,47 @@ +{ + "post": { + "ID": 1065, + "post_type": "et_template", + "post_status": "publish", + "post_title": "Standard-Website-Template", + "post_name": "standard-website-template", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 20:58:34", + "content_file": null, + "content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "meta": { + "_et_autogenerated_title": [ + "0" + ], + "_et_default": [ + "1" + ], + "_et_enabled": [ + "0" + ], + "_et_header_layout_id": [ + "1061" + ], + "_et_header_layout_enabled": [ + "1" + ], + "_et_body_layout_id": [ + "0" + ], + "_et_body_layout_enabled": [ + "1" + ], + "_et_footer_layout_id": [ + "0" + ], + "_et_footer_layout_enabled": [ + "1" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json b/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json new file mode 100644 index 0000000..6ddb0d6 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json @@ -0,0 +1,50 @@ +{ + "post": { + "ID": 1066, + "post_type": "et_template", + "post_status": "publish", + "post_title": "[Fixture] Vorlage Globale Instanzen", + "post_name": "fixture-vorlage-globale-instanzen", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 20:58:34", + "content_file": null, + "content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "meta": { + "_et_autogenerated_title": [ + "0" + ], + "_et_default": [ + "0" + ], + "_et_enabled": [ + "0" + ], + "_et_header_layout_id": [ + "1061" + ], + "_et_header_layout_enabled": [ + "1" + ], + "_et_body_layout_id": [ + "1062" + ], + "_et_body_layout_enabled": [ + "1" + ], + "_et_footer_layout_id": [ + "1063" + ], + "_et_footer_layout_enabled": [ + "1" + ], + "_et_use_on": [ + "singular:post_type:page:id:1053" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/theme-builder.json b/tests/Fixtures/divi/stage3/theme-builder.json new file mode 100644 index 0000000..2e76296 --- /dev/null +++ b/tests/Fixtures/divi/stage3/theme-builder.json @@ -0,0 +1,20 @@ +{ + "theme_builder_post": { + "ID": 1064, + "post_status": "publish", + "post_title": "Theme Builder", + "meta": { + "_et_template": [ + "1065", + "1066" + ] + } + }, + "template_ids_in_order": [ + 1065, + 1066 + ], + "draft_theme_builder_posts": [], + "options_et_tb": {}, + "marked_as_unused": [] +} diff --git a/tests/Fixtures/divi/synthetic-divi-filters.html b/tests/Fixtures/divi/synthetic-divi-filters.html index 2103459..e310d16 100644 --- a/tests/Fixtures/divi/synthetic-divi-filters.html +++ b/tests/Fixtures/divi/synthetic-divi-filters.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/Integration/divi-export-stage3-fixtures.php b/tests/Integration/divi-export-stage3-fixtures.php new file mode 100644 index 0000000..c4b2939 --- /dev/null +++ b/tests/Integration/divi-export-stage3-fixtures.php @@ -0,0 +1,185 @@ +.json per post (post row, all meta, + * all term relationships), the raw post_content byte-exact as .html, the live Theme + * Builder post with its template list and the et_tb_* options as theme-builder.json, and a + * manifest.json (role → post ID). How the fixtures were created: tests/Fixtures/divi/stage3/README.md. + * + * Usage (DDEV, WordPress root): + * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php + * + * Only posts titled "[Fixture] …" are exported, plus the Default Website Template when its + * layouts are fixture layouts. Read-only. Fixtures are committed – no personal data. + * No `declare(strict_types=1);` – wp eval-file runs the file through eval(). + */ + +defined('WP_CLI') && WP_CLI || exit; + +$lit_prefix = '[Fixture] '; +$lit_dir = dirname(__DIR__) . '/Fixtures/divi/stage3'; +$lit_skip_meta = ['_edit_lock', '_edit_last']; + +/** Role name for a fixture post, derived from post type, title and scope. */ +$lit_role = static function (WP_Post $post, array $terms): string { + $slug = sanitize_title(preg_replace('/^\[Fixture\]\s*/', '', $post->post_title)); + $prefix = [ + 'page' => 'page', + 'et_pb_layout' => in_array('global', array_column($terms['scope'] ?? [], 'slug'), true) + ? 'library-global' + : 'library', + 'et_template' => 'tb-template', + 'et_header_layout' => 'tb-header', + 'et_body_layout' => 'tb-body', + 'et_footer_layout' => 'tb-footer', + ][$post->post_type] ?? $post->post_type; + + return $prefix . '--' . $slug; +}; + +/** All term relationships by taxonomy – read from the tables, some Divi taxonomies are not registered under WP-CLI. */ +$lit_terms = static function (int $postId): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT tt.taxonomy, t.slug, t.name FROM {$wpdb->term_relationships} tr + JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id + JOIN {$wpdb->terms} t ON t.term_id = tt.term_id + WHERE tr.object_id = %d ORDER BY tt.taxonomy, t.slug", + $postId + )); + $terms = []; + foreach ($rows as $row) { + $terms[$row->taxonomy][] = ['slug' => $row->slug, 'name' => $row->name]; + } + + return $terms; +}; + +/** Raw meta rows (value as stored, multiple values kept in order). */ +$lit_meta = static function (int $postId) use ($lit_skip_meta): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d ORDER BY meta_id", + $postId + )); + $meta = []; + foreach ($rows as $row) { + if (! in_array($row->meta_key, $lit_skip_meta, true)) { + $meta[$row->meta_key][] = $row->meta_value; + } + } + + return $meta; +}; + +$lit_write = static function (string $file, string $data): void { + if (file_put_contents($file, $data) === false) { + WP_CLI::error('Cannot write ' . $file); + } +}; + +$lit_json = static fn (array $data): string => wp_json_encode( + $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE +) . "\n"; + +global $wpdb; +$lit_types = ['page', 'et_pb_layout', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout']; +$lit_ids = array_map('intval', $wpdb->get_col($wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type IN (" . implode(', ', array_fill(0, count($lit_types), '%s')) . ") + AND post_status NOT IN ('inherit', 'auto-draft') + AND post_title LIKE %s + ORDER BY ID", + ...[...$lit_types, $wpdb->esc_like($lit_prefix) . '%'] +))); + +// The live Theme Builder post and the Default Website Template, when it points to fixture layouts. +$lit_tb_id = (int) $wpdb->get_var( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'publish' ORDER BY ID LIMIT 1" +); +$lit_tb_templates = $lit_tb_id ? array_map('intval', get_post_meta($lit_tb_id, '_et_template', false)) : []; +foreach ($lit_tb_templates as $templateId) { + if (get_post_meta($templateId, '_et_default', true) !== '1') { + continue; + } + foreach (['header', 'body', 'footer'] as $area) { + $layoutId = (int) get_post_meta($templateId, "_et_{$area}_layout_id", true); + if ($layoutId > 0 && in_array($layoutId, $lit_ids, true) && ! in_array($templateId, $lit_ids, true)) { + $lit_ids[] = $templateId; + } + } +} + +if ($lit_ids === []) { + WP_CLI::error('No "[Fixture] " posts found – create them first (see tests/Fixtures/divi/stage3/README.md).'); +} + +if (! is_dir($lit_dir) && ! wp_mkdir_p($lit_dir)) { + WP_CLI::error('Cannot create ' . $lit_dir); +} +foreach (glob($lit_dir . '/*.{json,html}', GLOB_BRACE) ?: [] as $old) { + unlink($old); +} + +$lit_manifest = ['divi_version' => wp_get_theme('Divi')->get('Version'), 'posts' => []]; +foreach ($lit_ids as $postId) { + $post = get_post($postId); + $terms = $lit_terms($postId); + $role = $post->post_type === 'et_template' && get_post_meta($postId, '_et_default', true) === '1' + ? 'tb-template--default' + : $lit_role($post, $terms); + + $record = [ + 'post' => [ + 'ID' => $post->ID, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'post_author' => (int) $post->post_author, + 'post_parent' => $post->post_parent, + 'menu_order' => $post->menu_order, + 'post_excerpt' => $post->post_excerpt, + 'post_date_gmt' => $post->post_date_gmt, + 'post_modified_gmt' => $post->post_modified_gmt, + 'content_file' => $post->post_content === '' ? null : $role . '.html', + 'content_sha256' => hash('sha256', $post->post_content), + ], + 'meta' => (object) $lit_meta($postId), + 'terms' => (object) $terms, + ]; + $lit_write($lit_dir . '/' . $role . '.json', $lit_json($record)); + if ($post->post_content !== '') { + $lit_write($lit_dir . '/' . $role . '.html', $post->post_content); + } + $lit_manifest['posts'][$role] = $postId; +} + +$lit_options = []; +$lit_option_rows = $wpdb->get_results( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'et\\_tb\\_%' ORDER BY option_name" +); +foreach ($lit_option_rows as $row) { + $lit_options[$row->option_name] = $row->option_value; +} +$lit_write($lit_dir . '/theme-builder.json', $lit_json([ + 'theme_builder_post' => $lit_tb_id ? [ + 'ID' => $lit_tb_id, + 'post_status' => get_post_status($lit_tb_id), + 'post_title' => get_the_title($lit_tb_id), + 'meta' => (object) $lit_meta($lit_tb_id), + ] : null, + 'template_ids_in_order' => $lit_tb_templates, + 'draft_theme_builder_posts' => array_map('intval', $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'auto-draft'" + )), + 'options_et_tb' => (object) $lit_options, + 'marked_as_unused' => array_map('intval', $wpdb->get_col( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_et_theme_builder_marked_as_unused'" + )), +])); +$lit_write($lit_dir . '/manifest.json', $lit_json($lit_manifest)); + +WP_CLI::success(sprintf('%d posts exported to %s', count($lit_manifest['posts']), $lit_dir)); diff --git a/tests/Integration/divi-library-smoke.sh b/tests/Integration/divi-library-smoke.sh new file mode 100755 index 0000000..3a6f806 --- /dev/null +++ b/tests/Integration/divi-library-smoke.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy, {{link:ID}}) through the +# OAuth-protected endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh +# Creates only temporary data: draft/private pages and library entries whose titles start with +# "LIT Smoke ", a role and a user without unfiltered_html. The EXIT trap removes all of it by +# exact ID (posts with their revisions, change-log rows, user, role, app passwords) and restores the +# options it changed. [Fixture]/[Test] content is only read (fixture files), never written. +# Security probes (section 10) use benign markers only: data-probe attributes and an example.invalid URL. +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) +STAGE3=$PLUGIN/tests/Fixtures/divi/stage3 +RUN="$(date +%s)-$$" +PREFIX="LIT Smoke $RUN" +# Per-run names: cleanup never touches an account or role that existed before this run. +EDITOR_LOGIN="lit-lib-editor-$RUN" +EDITOR_ROLE="lit_lib_editor_${RUN//-/_}" +EDITOR_CREATED=0 +ROLE_CREATED=0 +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '__none__') +ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__') +CREATED_POSTS=() + +restore_roles() { + if [ "$ORIGINAL_ROLES" = "__none__" ]; then + wp option delete lit_mcp_allowed_roles >/dev/null 2>&1 || true + else + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true + fi +} + +restore_exposure() { + if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then + wp option delete lit_mcp_exposure >/dev/null 2>&1 || true + else + wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true + fi +} + +cleanup() { + # Pages and library entries of this run whose ID a stopped run could not record (title prefix with the run token). + local found + found=$(LIT_PREFIX="$PREFIX " wp eval ' +global $wpdb; +echo implode(" ", $wpdb->get_col($wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE post_type IN (%s, %s) AND post_title LIKE %s", + "page", + "et_pb_layout", + $wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%" +)));' 2>/dev/null || true) + for id in $found; do + CREATED_POSTS+=("$id") + done + # Change-log rows: every row of the temporary user and every row about a post of this run. + LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval ' +global $wpdb; +$table = $wpdb->prefix . "lit_mcp_log"; +$u = get_user_by("login", getenv("LIT_LOGIN")); +if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); } +foreach (array_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) { + foreach (["page", "et_pb_layout"] as $type) { + $wpdb->delete($table, ["object_type" => $type, "object_id" => $id]); + } +}' >/dev/null 2>&1 || true + # wp_delete_post(…, true) also deletes the revisions of each post. + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" + if [ "$EDITOR_CREATED" = 1 ]; then + cleanup_app_passwords_for "$EDITOR_LOGIN" + wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true + fi + if [ "$ROLE_CREATED" = 1 ]; then + wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true + fi + restore_roles + restore_exposure +} +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +# Creates a page as the admin (no kses) from markup and prints its ID. +create_page() { # $1 = markup, $2 = title suffix, $3 = status (default draft) + LIT_CONTENT="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="${3:-draft}" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => getenv("LIT_STATUS"), "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +# Exact stored bytes of a post (PHP, so no trailing newline is lost or added). +content_of() { # $1 = post ID + wp eval "echo (string) get_post($1)->post_content;" +} + +content_hash() { # $1 = post ID + wp eval "echo hash('sha256', (string) get_post($1)->post_content);" +} + +outline() { # $1 = post ID -> the lit-divi-outline result + tool lit-divi-outline "{\"post_id\":$1}" +} + +rev_of() { # $1 = post ID -> tree_rev + outline "$1" | jq -r '.tree_rev' +} + +# "yes" when the stored content equals $2 with exactly the bytes $3 inserted at one position. +inserted_once() { # $1 = post ID, $2 = content before, $3 = inserted bytes + LIT_ID="$1" LIT_BEFORE="$2" LIT_NEEDLE="$3" wp eval ' +$after = (string) get_post((int) getenv("LIT_ID"))->post_content; +$before = (string) getenv("LIT_BEFORE"); +$needle = (string) getenv("LIT_NEEDLE"); +$ok = $needle !== "" && strlen($after) === strlen($before) + strlen($needle); +for ($at = $ok ? strpos($after, $needle) : false, $ok = false; $at !== false; $at = strpos($after, $needle, $at + 1)) { + if (substr($after, 0, $at) . substr($after, $at + strlen($needle)) === $before) { $ok = true; break; } +} +echo $ok ? "yes" : "no";' +} + +# Divi's _et_* meta and the library terms of an entry, as sorted JSON (compared with the stage3 fixtures). +entry_shape() { # $1 = post ID + wp eval " +\$meta = []; +foreach (get_post_meta($1) as \$k => \$v) { if (str_starts_with(\$k, '_et_')) { \$meta[\$k] = \$v; } } +ksort(\$meta); +\$terms = []; +foreach (['layout_type', 'module_width', 'scope'] as \$t) { \$terms[\$t] = wp_get_object_terms($1, \$t, ['fields' => 'slugs']); } +echo wp_json_encode(['meta' => \$meta, 'terms' => \$terms]);" +} + +fixture_shape() { # $1 = stage3 fixture name + jq -cS '{meta: (.meta | with_entries(select(.key | startswith("_et_")))), + terms: {layout_type: [.terms.layout_type[]?.slug], module_width: [.terms.module_width[]?.slug], scope: [.terms.scope[]?.slug]}}' \ + "$STAGE3/$1.json" +} + +# Byte snippets from Divi's own stage3 saves (fixtures were written by the Visual Builder). +snippet() { # $1 = what -> prints the bytes + LIT_DIR="$STAGE3" LIT_WHAT="$1" wp eval ' +$dir = getenv("LIT_DIR"); +$read = static fn (string $f): string => (string) file_get_contents("$dir/$f.html"); +$unframe = static fn (string $c): string => substr($c, strlen(""), -strlen("")); +$page = $read("page--globale-instanzen"); +$from = strpos($page, "", $from) + 5 - $from); +$globalSection = substr($page, strlen(""), + strpos($page, "\n\n") + strlen("\n\n") - strlen("")); +$text = $unframe($read("library-global--globales-modul")); +switch (getenv("LIT_WHAT")) { + case "source": // the fixture page without global instances: the global module as a plain text module + echo str_replace([$globalSection, $instance], ["", $text], $page); break; + case "target": // the section "Modul Abschnitt" with only the local button + echo str_replace([$globalSection, $instance . "\n\n"], ["", ""], substr($page, 0, strpos($page, ""; break; + case "section-entry": echo str_replace("\r\n", "\n", $read("library--library-abschnitt")); break; + case "section": echo $unframe(str_replace("\r\n", "\n", $read("library--library-abschnitt"))); break; + case "module-entry": echo $read("library-global--globales-modul"); break; + case "instance": echo $instance; break; +}' +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" + +# 0. Temporary pages built from Divi's own stage3 bytes +SOURCE_ID=$(create_page "$(snippet source)" "Quelle"); CREATED_POSTS+=("$SOURCE_ID") +TARGET_ID=$(create_page "$(snippet target)" "Ziel"); CREATED_POSTS+=("$TARGET_ID") +SECTION_BYTES=$(snippet section) +content_of "$SOURCE_ID" | grep -qF "$SECTION_BYTES" || fail "setup: source page lacks the library section bytes" +if content_of "$SOURCE_ID" | grep -q 'globalModule'; then fail "setup: source page still holds a global instance"; fi +SOURCE_HASH=$(content_hash "$SOURCE_ID") +connect "$ADMIN_LOGIN" + +# 1. Exposure: lit-library-insert direct, the other new tools indirect, 22 direct lit tools +LIST=$(rpc tools/list '{}') +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint == false' >/dev/null \ + || fail "lit-library-insert is not a direct write tool" +for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do + if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi +done +DIRECT='["lit-content-create","lit-content-get","lit-content-query","lit-content-types","lit-content-update","lit-divi-catalog", +"lit-divi-delete","lit-divi-design-system","lit-divi-insert","lit-divi-move","lit-divi-node-get","lit-divi-outline","lit-divi-update", +"lit-guide","lit-library-insert","lit-media-download","lit-media-preview","lit-media-search","lit-media-upload-from-url", +"lit-page-render-summary","lit-post-publish","lit-site-status"]' +echo "$LIST" | jq -e --argjson want "$DIRECT" '[.result.tools[].name | select(startswith("lit-"))] | sort == ($want | sort) and length == 22' >/dev/null \ + || fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')" +for a in lit/library-list lit/library-save lit/divi-node-copy lit/divi-dynamic-sources; do + tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \ + || fail "$a is not reachable as an indirect ability" +done +pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect, 22 direct tools" + +# 2. Save a section as normal entry: bytes, terms and meta as Divi's own entry, no Divi flags, source untouched +REV=$(rev_of "$SOURCE_ID") +SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Abschnitt" \ + '{post_id: $id, tree_rev: $rev, id: "s1", title: $t}')") +LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty') +[ -n "$LIB_ID" ] || fail "library-save: $SAVED" +CREATED_POSTS+=("$LIB_ID") +[ "$(content_of "$LIB_ID")" = "$(snippet section-entry)" ] || fail "normal entry bytes differ from Divi's entry: $(content_of "$LIB_ID")" +[ "$(entry_shape "$LIB_ID" | jq -cS .)" = "$(fixture_shape library--library-abschnitt)" ] \ + || fail "normal entry terms/meta: $(entry_shape "$LIB_ID")" +[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags" +[ "$(wp post get "$LIB_ID" --field=post_status)" = "publish" ] || fail "library entry is not published" +echo "$SAVED" | jq -e --arg r "$(rev_of "$LIB_ID")" '.tree_rev == $r' >/dev/null || fail "library-save tree_rev: $SAVED" +[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "library-save changed the source page" +pass "lit/library-save normal: bytes, terms and meta like Divi's entry, no Divi flags, source unchanged" + +# 3. Save the text module as global entry; library-list shows both entries +GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Globales Modul" \ + '{post_id: $id, tree_rev: $rev, id: "s0.r0.c0.m0", title: $t, scope: "global"}')") +GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty') +[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED" +CREATED_POSTS+=("$GLOB_ID") +[ "$(content_of "$GLOB_ID")" = "$(snippet module-entry)" ] || fail "global entry bytes differ from Divi's entry: $(content_of "$GLOB_ID")" +[ "$(entry_shape "$GLOB_ID" | jq -cS .)" = "$(fixture_shape library-global--globales-modul)" ] \ + || fail "global entry terms/meta: $(entry_shape "$GLOB_ID")" +[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "global library-save changed the source page" +LISTED=$(ability lit/library-list "$(jq -cn --arg s "$PREFIX" '{search: $s}')") +echo "$LISTED" | jq -e --argjson l "$LIB_ID" --argjson g "$GLOB_ID" ' + (.items | map(select(.id == $l and .scope == "normal" and .layout_type == "section")) | length == 1) and + (.items | map(select(.id == $g and .scope == "global" and .layout_type == "module")) | length == 1)' >/dev/null \ + || fail "library-list: $LISTED" +ability lit/library-list "{\"id\":$GLOB_ID}" | jq -e '.outline | length > 0' >/dev/null || fail "library-list detail of $GLOB_ID" +pass "lit/library-save global: bytes, terms and meta like Divi's entry; lit/library-list shows both" + +# A normal section entry with the CRLF bytes of Divi's library dialog (stage3 fixture as-is, finding 9). +CRLF_ID=$(LIT_FILE="$STAGE3/library--library-abschnitt.html" LIT_TITLE="$PREFIX CRLF" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "et_pb_layout", "post_status" => "publish", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); +wp_set_object_terms($id, "section", "layout_type"); +wp_set_object_terms($id, "regular", "module_width"); +wp_set_object_terms($id, "non_global", "scope"); +update_post_meta($id, "_et_pb_template_type", "section"); +update_post_meta($id, "_et_pb_built_for_post_type", "page"); +echo $id;') +CREATED_POSTS+=("$CRLF_ID") +content_of "$CRLF_ID" | grep -q $'\r' || fail "setup: the CRLF entry holds no CR" + +# 4. Insert the global entry as instance, the CRLF entry as copy: bytes as the Visual Builder writes them +BEFORE=$(content_of "$TARGET_ID") +INST=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(rev_of "$TARGET_ID")" --argjson lib "$GLOB_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: "s0.r0.c0", index: 0}')") +echo "$INST" | jq -e '.ok == true and .changed[0].id == "s0.r0.c0.g0"' >/dev/null || fail "library-insert global: $INST" +EXPECTED_INSTANCE=$(snippet instance | sed "s/\"globalModule\":\"1059\"/\"globalModule\":\"$GLOB_ID\"/") +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$EXPECTED_INSTANCE")" = "yes" ] \ + || fail "instance bytes differ from the Visual Builder's: $(content_of "$TARGET_ID")" +outline "$TARGET_ID" | jq -e --arg g "$GLOB_ID" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance" +BEFORE=$(content_of "$TARGET_ID") +COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$CRLF_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") +echo "$COPY" | jq -e '.ok == true and .changed[0].id == "s1"' >/dev/null || fail "library-insert copy: $COPY" +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "copy bytes differ from the entry's section" +if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi +pass "lit-library-insert: instance and copy byte-exact in the Visual Builder's format" + +# 5. Global edit: dry run lists the pages, confirm changes the entry with a revision, page bytes unchanged +PRIVATE_ID=$(create_page "$EXPECTED_INSTANCE" "Privat" private) +CREATED_POSTS+=("$PRIVATE_ID") +TARGET_HASH=$(content_hash "$TARGET_ID") +PRIVATE_HASH=$(content_hash "$PRIVATE_ID") +GLOB_HASH=$(content_hash "$GLOB_ID") +GREV=$(rev_of "$GLOB_ID") +CHANGE='[{"id":"m0","set":{"content":"

LIT Smoke geändert

"}}]' +DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "global edit without confirm: $DRY" +echo "$DRY" | jq -r '.__error' | jq -e --argjson t "$TARGET_ID" --argjson p "$PRIVATE_ID" \ + '(.affected | map(.post_id) | (index($t) != null and index($p) != null)) and .affected_count >= 2 and (has("blocked_ids") | not)' >/dev/null \ + || fail "dry run: $DRY" +[ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "dry run wrote something" +REVISIONS=$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));") +DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')") +echo "$DONE" | jq -e '.ok == true and .revision_id != null and .affected_count >= 2' >/dev/null || fail "confirmed global edit: $DONE" +[ "$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));")" -gt "$REVISIONS" ] || fail "confirmed global edit made no revision" +[ "$(wp post get "$(echo "$DONE" | jq -r .revision_id)" --field=post_parent)" = "$GLOB_ID" ] || fail "revision_id is not a revision of $GLOB_ID" +content_of "$GLOB_ID" | grep -q 'LIT Smoke geändert' || fail "the entry was not changed" +[ -z "$(wp post meta get "$GLOB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "global edit set Divi flags" +[ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "the global edit changed the page bytes" +[ "$(content_hash "$PRIVATE_ID")" = "$PRIVATE_HASH" ] || fail "the global edit changed the private page bytes" +# Divi does not render global modules outside a front-end request; the summary names the instance instead. +tool lit-page-render-summary "{\"post_id\":$TARGET_ID}" | jq -e --arg g "Globales Modul $GLOB_ID " '.notes | map(contains($g)) | any' >/dev/null \ + || fail "render summary does not note the global module" +pass "global edit: dry run lists the pages, confirm changes the entry with a revision, pages keep their bytes" + +# 6. Copy a node between pages; a stale source_tree_rev writes nothing +TREV=$(rev_of "$TARGET_ID") +STALE=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ + '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: "abcdef012345"}')") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale source_tree_rev: $STALE" +[ "$(rev_of "$TARGET_ID")" = "$TREV" ] || fail "stale node-copy wrote something" +BEFORE=$(content_of "$TARGET_ID") +NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ + --arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')") +echo "$NODE_COPY" | jq -e '.ok == true and .revision_id != null' >/dev/null || fail "node-copy: $NODE_COPY" +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy bytes differ from the source node" +[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "node-copy changed the source page" +BEFORE=$(content_of "$TARGET_ID") +LIB_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$NODE_COPY" | jq -r .tree_rev)" \ + --argjson src "$CRLF_ID" --arg srev "$(rev_of "$CRLF_ID")" \ + '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')") +echo "$LIB_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy from the CRLF entry: $LIB_COPY" +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy from a library entry: bytes differ" +if content_of "$TARGET_ID" | grep -q $'\r'; then fail "node-copy brought CRLF into the page"; fi +NODE_COPY=$LIB_COPY +pass "lit/divi-node-copy: stale source_tree_rev refused, copy byte-exact (CRLF unified from a library entry), source unchanged" + +# 7. {{link:ID}} in a button URL: Divi's post_link_url_page; a draft target renders only ?page_id= +TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev') +LINKED=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg u "{{link:$SOURCE_ID}}" \ + '{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "button", text: "Zur Quelle", url: $u}]}')") +echo "$LINKED" | jq -e '.ok == true' >/dev/null || fail "{{link}} in a button: $LINKED" +LINK_VALUE=$(LIT_ID="$TARGET_ID" wp eval ' +$find = static function (array $blocks) use (&$find): ?string { + foreach ($blocks as $b) { + $text = $b["attrs"]["button"]["innerContent"]["desktop"]["value"]["text"] ?? null; + if ($b["blockName"] === "divi/button" && $text === "Zur Quelle") { + return (string) ($b["attrs"]["button"]["innerContent"]["desktop"]["value"]["linkUrl"] ?? ""); + } + $in = $find($b["innerBlocks"] ?? []); + if ($in !== null) { return $in; } + } + return null; +}; +echo $find(parse_blocks((string) get_post((int) getenv("LIT_ID"))->post_content));') +echo "$LINK_VALUE" | grep -qE "^\\\$variable\(\{\"type\":\"content\",\"value\":\{\"name\":\"post_link_url_page\",\"settings\":\{\"post_id\":\"$SOURCE_ID\"\}\}\}\)\\\$\$" \ + || fail "{{link}} not stored as Divi's post_link_url_page: $LINK_VALUE" +RENDERED=$(LIT_ID="$TARGET_ID" wp eval 'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));') +echo "$RENDERED" | grep -qE "href=\"[^\"]*\\?page_id=$SOURCE_ID\"" || fail "draft link target does not render as ?page_id=$SOURCE_ID" +if echo "$RENDERED" | grep -q "$PREFIX Quelle"; then fail "the rendered link reveals the draft's title"; fi +TEXT_LINK=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$LINKED" | jq -r .tree_rev)" --arg u "{{link:$SOURCE_ID}}" \ + '{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "heading", text: $u}]}')") +[ "$(error_code "$TEXT_LINK")" = "validation_failed" ] || fail "{{link}} in a text field: $TEXT_LINK" +pass "{{link:ID}} stored as post_link_url_page, draft target renders only ?page_id=, refused in text fields" + +# 8. Carry-overs: guide "all", site-status compatibility, dynamic sources for page +tool lit-guide '{"topic":"all"}' | jq -e '.content | contains("divi-library") and contains("lit-library-insert")' >/dev/null || fail "guide all" +STATUS=$(tool lit-site-status '{}') +echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .active == true and .problems == []' >/dev/null \ + || fail "Divi compatibility findings: $STATUS" +SOURCES=$(ability lit/divi-dynamic-sources '{"post_type":"page"}') +echo "$SOURCES" | jq -e '.post_type == "page" and (.sources | length > 0) and + (.sources | map(select(.name == "post_title" and (.placeholder // "" | startswith("{{post:")))) | length == 1) and + (.sources | map(select(.name == "post_link_url_page" and .placeholder == "{{link:}}")) | length == 1) and + (.sources | map(select(.name | startswith("loop_"))) | length == 0) and + ([.fields[].key] | map(select(startswith("_"))) | length == 0)' >/dev/null || fail "dynamic sources: $SOURCES" +pass "lit-guide all, lit-site-status without Divi findings, lit/divi-dynamic-sources for page" + +# 9. Probe page (admin-authored, benign markers): interactions, Lottie source, custom HTML +PROBE_ID=$(LIT_TITLE="$PREFIX Probe" wp --user="$ADMIN_LOGIN" eval ' +$v = static fn ($x): array => ["desktop" => ["value" => $x]]; +$wrap = static fn (array $module): array => ["blockName" => "divi/section", "attrs" => [], "innerContent" => [null], + "innerBlocks" => [["blockName" => "divi/row", "attrs" => [], "innerContent" => [null], "innerBlocks" => [[ + "blockName" => "divi/column", "attrs" => ["module" => ["decoration" => ["sizing" => $v(["flexType" => "24_24"])]]], + "innerContent" => [null], "innerBlocks" => [$module]]]]]]; +$module = static fn (string $name, array $attrs): array => ["blockName" => $name, "attrs" => $attrs, "innerBlocks" => [], "innerContent" => []]; +$text = ["innerContent" => $v("

Probe

")]; +$sections = [ + $wrap($module("divi/text", ["content" => $text, "module" => ["decoration" => ["interactions" => $v(["interactions" => [[ + "id" => "probe", "trigger" => "click", "effect" => "addAttribute", "attributeName" => "data-probe", "attributeValue" => "1"]]])]]])), + $wrap($module("divi/lottie", ["lottie" => ["innerContent" => $v(["src" => "https://example.invalid/data-probe.json"])]])), + $wrap($module("divi/text", ["content" => $text, "module" => ["advanced" => ["html" => $v(["htmlBefore" => "Probe"])]]])), +]; +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash(serialize_block(["blockName" => "divi/placeholder", "attrs" => [], "innerBlocks" => $sections, + "innerContent" => array_fill(0, count($sections), null)]))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;') +CREATED_POSTS+=("$PROBE_ID") +PROBE_REV=$(rev_of "$PROBE_ID") +PROBE_ENTRIES=() +for s in s0 s1 s2; do + P=$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe $s" \ + '{post_id: $id, tree_rev: $rev, id: $s, title: $t}')") + PID=$(echo "$P" | jq -r '.library_id // empty') + [ -n "$PID" ] || fail "admin library-save of probe $s: $P" + CREATED_POSTS+=("$PID") + PROBE_ENTRIES+=("$PID") +done +pass "admin (unfiltered_html) may save interactions, Lottie source and custom HTML to the library" + +# 10. Temporary editor without unfiltered_html and without edit_private_pages +if wp user get "$EDITOR_LOGIN" >/dev/null 2>&1 || wp role exists "$EDITOR_ROLE" >/dev/null 2>&1; then + fail "user $EDITOR_LOGIN or role $EDITOR_ROLE exists already" +fi +wp role create "$EDITOR_ROLE" "LIT Smoke Bibliothek" --clone=editor >/dev/null +ROLE_CREATED=1 +wp cap remove "$EDITOR_ROLE" unfiltered_html edit_private_pages >/dev/null +wp option update lit_mcp_allowed_roles "[\"administrator\",\"$EDITOR_ROLE\"]" --format=json >/dev/null +wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role="$EDITOR_ROLE" >/dev/null +EDITOR_CREATED=1 +[ "$(LIT_LOGIN="$EDITOR_LOGIN" LIT_ID="$PRIVATE_ID" wp eval ' +$u = get_user_by("login", getenv("LIT_LOGIN")); +echo (user_can($u, "unfiltered_html") ? "html " : "") . (user_can($u, "edit_post", (int) getenv("LIT_ID")) ? "private" : "");')" = "" ] \ + || fail "the editor must lack unfiltered_html and edit_post on the private page" +connect "$EDITOR_LOGIN" + +# 10a. P8a: the private page blocks the confirmed global edit; the dry run names it in blocked_ids +GLOB_HASH=$(content_hash "$GLOB_ID") +GREV=$(rev_of "$GLOB_ID") +CHANGE='[{"id":"m0","set":{"content":"

LIT Smoke Redakteur

"}}]' +DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "editor dry run: $DRY" +echo "$DRY" | jq -r '.__error' | jq -e --argjson p "$PRIVATE_ID" '.blocked_ids == [$p]' >/dev/null || fail "dry run lacks blocked_ids: $DRY" +REFUSED=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')") +[ "$(error_code "$REFUSED")" = "forbidden" ] || fail "confirmed global edit with a blocked private page: $REFUSED" +echo "$REFUSED" | grep -q "$PRIVATE_ID" || fail "refusal does not name the private page ID: $REFUSED" +if echo "$REFUSED" | grep -q "$PREFIX Privat"; then fail "refusal reveals the private page's title"; fi +[ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "refused global edit wrote something" +pass "P8a: editor without rights on a private page gets blocked_ids in the dry run and forbidden on confirm" + +# 10b. Control: the editor may copy benign nodes and entries +TREV=$(rev_of "$TARGET_ID") +OK_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ + --arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')") +echo "$OK_COPY" | jq -e '.ok == true' >/dev/null || fail "editor node-copy of a benign section: $OK_COPY" +OK_INSERT=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$OK_COPY" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") +echo "$OK_INSERT" | jq -e '.ok == true' >/dev/null || fail "editor library-insert copy of a benign entry: $OK_INSERT" +pass "editor without unfiltered_html copies benign nodes and entries" + +# 10c. Interactions, Lottie source and custom HTML: refused by library-save, library-insert copy and node-copy +TARGET_HASH=$(content_hash "$TARGET_ID") +TREV=$(rev_of "$TARGET_ID") +PROBE_REV=$(rev_of "$PROBE_ID") +PROBE_PATHS=(decoration.interactions lottie.innerContent advanced.html) +refused() { # $1 = result, $2 = context, $3 = path the refusal must name + [ "$(error_code "$1")" = "validation_failed" ] || fail "$2 not refused: $1" + echo "$1" | grep -q 'Divi-Builder' && echo "$1" | grep -qF "$3" || fail "$2 refused for another reason: $1" +} +i=0 +for s in s0 s1 s2; do + p=${PROBE_PATHS[$i]} + refused "$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe Redakteur $s" \ + '{post_id: $id, tree_rev: $rev, id: $s, title: $t}')")" "library-save $s" "$p" + refused "$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "${PROBE_ENTRIES[$i]}" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")" "library-insert copy $s" "$p" + refused "$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PROBE_ID" --arg s "$s" \ + --arg srev "$PROBE_REV" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")" "node-copy $s" "$p" + i=$((i + 1)) +done +[ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "a refused probe wrote to the target page" +[ "$(wp post list --post_type=et_pb_layout --post_status=any --s="$PREFIX Probe Redakteur" --format=count)" = "0" ] \ + || fail "a refused library-save created an entry" +if content_of "$TARGET_ID" | grep -q 'data-probe'; then fail "a probe marker reached the target page"; fi +pass "editor: interactions, Lottie source and custom HTML refused by library-save, library-insert copy and node-copy" + +# 11. Group "Divi-Bibliothek" disabled: library IDs not_found in the Divi read tools, library tools gone +connect "$ADMIN_LOGIN" +[ "$(error_code "$(outline "$LIB_ID")")" = "" ] || fail "library entry not readable before disabling the group" +wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null +connect "$ADMIN_LOGIN" +LIST=$(rpc tools/list '{}') +if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then fail "lit-library-insert listed with the group disabled"; fi +for a in lit/library-list lit/library-save lit/library-insert; do + GONE=$(ability "$a" '{}') + echo "$GONE" | jq -e 'has("__error")' >/dev/null && echo "$GONE" | grep -q 'not found' || fail "$a still executable: $GONE" +done +for id in "$LIB_ID" "$GLOB_ID"; do + [ "$(error_code "$(outline "$id")")" = "not_found" ] || fail "outline of library entry $id with the group disabled" + [ "$(error_code "$(tool lit-divi-node-get "{\"post_id\":$id,\"id\":\"s0\"}")")" = "not_found" ] || fail "node-get of $id with the group disabled" +done +[ "$(error_code "$(outline "$TARGET_ID")")" = "" ] || fail "pages must stay readable with the library group disabled" +restore_exposure +connect "$ADMIN_LOGIN" +rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "exposure not restored" +pass "group Divi-Bibliothek disabled: library IDs not_found in outline/node-get, library tools gone; option restored" + +finish diff --git a/tests/Stubs/EtCorePageResource.php b/tests/Stubs/EtCorePageResource.php new file mode 100644 index 0000000..1b09b7f --- /dev/null +++ b/tests/Stubs/EtCorePageResource.php @@ -0,0 +1,19 @@ + */ + public static array $removed = []; + + public static function remove_static_resources($postId, $owner, $force = false): void + { + self::$removed[] = [$postId, $owner, (bool) $force]; + } + } +} diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index aec6f11..a8abb15 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; +use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; @@ -128,9 +129,24 @@ final class ComposeFixture }; } + /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */ + public static function links(): LinkTargets + { + return new class implements LinkTargets { + public function postTypeOf(int $id): ?string + { + return match ($id) { + 839 => 'page', + 840 => 'post', + default => null, + }; + } + }; + } + public static function placeholders(): Placeholders { - return new Placeholders(DiviFixtures::designSystem()); + return new Placeholders(DiviFixtures::designSystem(), self::links()); } /** The strict $variable(…)$ checker for the sample design system and the default map. */ diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php index e37e2ec..07deb20 100644 --- a/tests/Support/DiviWriterFixture.php +++ b/tests/Support/DiviWriterFixture.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; @@ -98,7 +99,8 @@ final class DiviWriterFixture array &$saved, array &$failures = [], bool $unfiltered = false, - ?DiviPageLoader $loader = null + ?DiviPageLoader $loader = null, + ?GlobalEdits $globals = null ): DiviWriter { $design = self::designReader(); @@ -107,7 +109,8 @@ final class DiviWriterFixture self::saver($saved, $failures), self::log(), $design, - self::composition($design, $unfiltered) + self::composition($design, $unfiltered), + $globals ); } } diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php new file mode 100644 index 0000000..f59f782 --- /dev/null +++ b/tests/Support/LibraryFixtures.php @@ -0,0 +1,142 @@ + ID => [fixture file, layout type, global]. */ + public const ITEMS = [ + 1054 => ['library--layout-ganze-seite.html', 'layout', false], + 1055 => ['library--library-abschnitt.html', 'section', false], + 1056 => ['library--library-zeile.html', 'row', false], + 1057 => ['library--library-modul.html', 'module', false], + 1058 => ['library-global--globaler-abschnitt.html', 'section', true], + 1059 => ['library-global--globales-modul.html', 'module', true], + ]; + + /** @param array $fields */ + public static function item(int $id, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => DiviLibraryStore::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => '[Fixture] ' . $id, + 'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]), + ], $fields)); + } + + /** @param array $fields */ + public static function page(string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => self::PAGE, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Start', + 'post_content' => $content, + ], $fields)); + } + + /** + * WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied. + * + * @param list $posts + * @param list $denied + */ + public static function given(array $posts, array $denied = []): void + { + $byId = []; + foreach ($posts as $post) { + $byId[(int) $post->ID] = $post; + } + Functions\when('get_post')->alias( + static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true) + ); + Functions\when('get_post_type_object')->justReturn(null); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf( + 'https://kunde.test/wp-admin/post.php?post=%d&action=edit', + $post instanceof WP_Post ? $post->ID : (int) $post + )); + Functions\when('add_query_arg')->alias( + static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value + ); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + } + + /** A store that answers isGlobal()/layoutType() from ITEMS (no database). */ + public static function store(): DiviLibraryStore + { + $store = Mockery::mock(DiviLibraryStore::class)->makePartial(); + $store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false); + $store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null); + + return $store; + } + + /** @param list $deniedRoles Divi role options the user lacks. */ + public static function roles(array $deniedRoles = []): DiviRoles + { + return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true)); + } + + /** @param list $deniedRoles */ + public static function access( + array $deniedRoles = [], + bool $writable = true, + ?DiviLibraryStore $store = null + ): LibraryAccess { + return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); + } + + /** + * GlobalUsages whose query answers $rows (a closure gets the entry ID on every call); the rights checks + * are the real ones, with the post-cache functions stubbed. + * + * @param list|\Closure(int): list $rows + */ + public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages + { + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial(); + $usages->allows('rows')->andReturnUsing(static fn (int $id): array => is_array($rows) ? $rows : $rows($id)); + + return $usages; + } + + /** @return list One row per ID. */ + public static function rows(array $ids, string $status = 'publish', string $type = 'page'): array + { + return array_map(static fn (int $id): array => ['id' => $id, 'status' => $status, 'type' => $type], $ids); + } + + public static function loader(?LibraryAccess $access = null): DiviPageLoader + { + return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); + } +} diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index 7f8b8e1..3c695e8 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -200,6 +200,16 @@ final class ExposurePolicyTest extends TestCase self::assertSame([], $policy->directNames([$update, $delete, $outline])); } + /** Ruling P9: only a group disabled as a whole is switched off; "nur lesen" still lets it read. */ + public function testGroupEnabledUnlessTheWholeGroupIsDisabled(): void + { + self::assertTrue((new ExposurePolicy())->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@readonly:divi-library' => true]))->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupEnabled('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi' => 'disabled']))->groupEnabled('divi-library')); + } + public function testSanitizeValidatesGroupEntries(): void { self::assertSame( @@ -311,4 +321,12 @@ final class ExposurePolicyTest extends TestCase self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available'); } + + public function testGroupWritable(): void + { + self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library')); + } } diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index 3d14b42..cfffae5 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -96,4 +96,29 @@ final class WriteFailedTest extends TestCase self::assertSame([7, 811], [$partial->postId, $partial->revisionId]); self::assertArrayNotHasKey('post_id', $clean->toError()->get_error_data()); } + + /** Ruling P14: details (and hint/state) added after the fact, everything else kept. */ + public function testWithDetailsAddsDetailsAndKeepsTheRest(): void + { + $cause = new RuntimeException('hook'); + $failure = WriteFailed::rolledBack('x', $cause, 'intern', details: ['a' => 1], postId: 5, revisionId: 811); + + $extended = $failure->withDetails(['backup_id' => 9], 'Sicherung 9 liegt im Papierkorb.', 'Bleibt.'); + + self::assertNotSame($failure, $extended); + self::assertTrue($extended->rolledBack); + self::assertSame($cause, $extended->getPrevious()); + self::assertSame($failure->getMessage(), $extended->getMessage()); + self::assertSame([5, 811], [$extended->postId, $extended->revisionId]); + $data = $extended->toError()->get_error_data(); + self::assertSame(1, $data['a']); + self::assertSame(9, $data['backup_id']); + self::assertSame('Sicherung 9 liegt im Papierkorb.', $data['hint']); + self::assertStringContainsString('Bleibt.', $extended->toError()->get_error_message()); + + $partial = WriteFailed::partial('x', postId: 5)->withDetails(['backup_id' => 9]); + self::assertFalse($partial->rolledBack); + self::assertSame(9, $partial->toError()->get_error_data()['backup_id']); + self::assertSame(5, $partial->toError()->get_error_data()['post_id']); + } } diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php index 9cac3b0..723b0a4 100644 --- a/tests/Unit/Guides/InstructionsBuilderTest.php +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -63,6 +63,47 @@ final class InstructionsBuilderTest extends TestCase self::assertLessThan(2000, strlen($with)); } + private function library(): Integration + { + $library = Mockery::mock(Integration::class); + $library->allows('id')->andReturn('divi-library'); + $library->allows('isAvailable')->andReturn(true); + $insert = $this->ability('lit/library-insert', true); + $insert->allows('annotations')->andReturn(['readonly' => false]); + $library->allows('abilities')->andReturn([$insert]); + + return $library; + } + + public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void + { + $core = $this->coreWith($this->ability('lit/guide', true)); + + $with = (new InstructionsBuilder(new IntegrationRegistry($core, $this->library())))->build(); + $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('Divi-Bibliothek: globale Module', $with); + self::assertStringContainsString('confirm: true', $with); + self::assertStringContainsString('topic "divi-library"', $with); + self::assertStringNotContainsString('Divi-Bibliothek:', $without); + self::assertLessThan(2000, strlen($with)); + } + + /** "nur lesen" or disabled: the library cannot be written, so the edit rule would only mislead. */ + public function testLeavesOutTheLibraryRuleWhenTheGroupCannotWrite(): void + { + $core = $this->coreWith($this->ability('lit/guide', true)); + $registry = new IntegrationRegistry($core, $this->library()); + + foreach ([['@readonly:divi-library' => true], ['@group:divi-library' => 'disabled']] as $settings) { + $policy = (new ExposurePolicy($settings))->withGroups($registry->groupMap()); + + $text = (new InstructionsBuilder($registry, null, $policy))->build(); + + self::assertStringNotContainsString('Divi-Bibliothek:', $text, (string) json_encode($settings)); + } + } + public function testListsPostTypesWhenAProviderIsGiven(): void { $core = $this->coreWith($this->ability('lit/guide', true)); diff --git a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php new file mode 100644 index 0000000..2dfb442 --- /dev/null +++ b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php @@ -0,0 +1,29 @@ +allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])); + $components = array_column($probe->components(), null, 'id'); + + self::assertSame(['GlobalLayout'], $components['divi']['problems']); + self::assertSame([], $components['pods']['problems']); + } +} diff --git a/tests/Unit/Integrations/Core/GuideAbilityTest.php b/tests/Unit/Integrations/Core/GuideAbilityTest.php index a669e86..10d30dd 100644 --- a/tests/Unit/Integrations/Core/GuideAbilityTest.php +++ b/tests/Unit/Integrations/Core/GuideAbilityTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Core; +use Brain\Monkey\Filters; use LIT\MCP\Guides\GuideRepository; use LIT\MCP\Integrations\Core\GuideAbility; use LIT\MCP\Tests\Unit\TestCase; @@ -50,4 +51,31 @@ final class GuideAbilityTest extends TestCase self::assertTrue($this->ability->isDirect()); self::assertTrue($this->ability->annotations()['readonly']); } + + public function testAllTopicsInOneCallOverviewFirst(): void + { + $result = $this->ability->execute(['topic' => 'all']); + + self::assertSame('all', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + $overview = strpos($result['content'], 'Thema `overview`'); + $shortform = strpos($result['content'], 'Thema `divi-shortform`'); + self::assertIsInt($overview); + self::assertIsInt($shortform); + self::assertLessThan($shortform, $overview); + self::assertStringNotContainsString('Nicht enthalten', $result['content']); + } + + public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void + { + $big = str_repeat('x', GuideAbility::ALL_LIMIT); + // Brain Monkey owns apply_filters(): answer the guide filter through its hooks API. + Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]); + $ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + + $content = $ability->execute(['topic' => 'all'])['content']; + + self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content)); + self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php index ed2fffc..0c1605e 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; use stdClass; @@ -74,4 +75,31 @@ final class BlockTest extends TestCase self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']); self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']); } + + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame( + "\n\n", + Serializer::block($row) + ); + self::assertFalse($row->copyAs('divi/row', [])->isOpaque()); + } + + public function testCopyAsRefusesInvalidNames(): void + { + $this->expectException(InvalidArgumentException::class); + + Block::create('divi/text')->copyAs('Divi Text', []); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php index 8fd85d9..13aecc8 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; @@ -51,4 +52,52 @@ final class ContainerTest extends TestCase $this->expectException(LogicException::class); $void->append('text'); } + + public function testReplaceChildKeepsTheTextAround(): void + { + $document = (new Parser())->parse("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } + + /** Global instances stay opaque: their bytes are Divi's, even the whitespace inside. */ + public function testUnifyLineBreaksLeavesOpaqueBlocksAlone(): void + { + $markup = "\r\n\r\n" + . "\r\n"; + $document = (new Parser())->parse($markup); + + $document->unifyLineBreaks(); + + self::assertSame(substr($markup, 0, -2) . "\n", Serializer::document($document)); + } + + public function testReplaceChildRefusesOnAnOpaqueBlock(): void + { + $document = (new Parser())->parse( + '' + ); + $instance = $document->children()[0]; + + $this->expectException(LogicException::class); + + $instance->replaceChild($instance->children()[0], Block::create('divi/button')); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php new file mode 100644 index 0000000..5c4676e --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php @@ -0,0 +1,106 @@ +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + 'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'], + ]; + } + + /** @dataProvider instances */ + public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''; + $instance = (new Parser())->parse($markup)->children()[0]; + + $result = GlobalInstance::deglobalize($instance); + + self::assertSame($instance, $result); + self::assertSame($markup, Serializer::block($result)); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php new file mode 100644 index 0000000..ccb8996 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php @@ -0,0 +1,103 @@ + */ + public static function savedNodes(): array + { + return [ + 'section' => ['s1', 'library--library-abschnitt.html'], + 'row' => ['s1.r0', 'library--library-zeile.html'], + 'module' => ['s1.r0.c0.m0', 'library--library-modul.html'], + ]; + } + + /** + * The builder's "add to library" wraps the node in divi/placeholder (F4); the fixtures only + * differ by the CRLF of the form upload (finding 9). + * + * @dataProvider savedNodes + */ + public function testWrapMatchesTheBuilderEntry(string $id, string $file): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/' . $file)), + LibraryContent::wrap($index->find($id)) + ); + } + + public function testLoadUnifiesLineBreaksAndRootsSkipThePlaceholder(): void + { + $document = LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/library--layout-ganze-seite.html')); + + self::assertStringNotContainsString("\r", Serializer::document($document)); + self::assertSame( + ['divi/section', 'divi/section', 'divi/section'], + array_map(static fn (Block $block): string => $block->name(), LibraryContent::roots($document)) + ); + self::assertSame( + ['divi/text'], + array_map( + static fn (Block $block): string => $block->name(), + LibraryContent::roots((new Parser())->parse('')) + ) + ); + } + + public function testContainsGlobal(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertTrue(LibraryContent::containsGlobal($index->find('s0'))); + self::assertFalse(LibraryContent::containsGlobal($index->find('s1'))); + } + + public function testShapeViolations(): void + { + $section = Block::create('divi/section'); + $row = Block::create('divi/row'); + $instance = GlobalInstance::of(Block::create('divi/text'), 9); + $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [ + Block::create('divi/column', [], [$instance]), + ])]); + + self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false)); + self::assertStringContainsString( + 'nicht leer', + (string) LibraryContent::shapeViolation([$section], [], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$row], null, false) + ); + self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false)); + self::assertStringContainsString( + 'nur Abschnitte', + (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false) + ); + self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false)); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 465bb19..d3a0155 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; use stdClass; @@ -26,7 +27,7 @@ final class OutlineTest extends TestCase self::assertSame( ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3, - 'truncated' => false], + 'truncated' => false, 'global_id' => null], $rows[5] ); self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); @@ -40,6 +41,22 @@ final class OutlineTest extends TestCase ); } + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline(); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertSame('1058', $byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertFalse($byId['g0']['editable']); + self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']); + self::assertSame('text', $byId['s0.r0.c0.g0']['type']); + self::assertNull($byId['s0']['global_id']); + self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows)); + } + public function testSubtreeAndDepthLimit(): void { $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); @@ -104,7 +121,7 @@ final class OutlineTest extends TestCase $text = (new Outline())->text((new Outline())->rows($index)); self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text); - self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text); + self::assertStringContainsString('s1.g1 row "Globale Zeile" [global 913] (nur lesbar)', $text); self::assertStringContainsString('s0 section [specialty]', $text); self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text); } diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 579236f..a8a5adf 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; +use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -376,4 +378,81 @@ final class AttrWriterTest extends TestCase $heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs'); self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure'); } + + /** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */ + public function testInteractionsNeedUnfilteredHtml(): void + { + $interaction = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $changes = ['module.decoration.interactions' => ['desktop' => $interaction]]; + + $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame(self::TEXT, $editor['attrs']); + $key = 'attrs.module.decoration.interactions'; + self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); + + $remove = ['module.decoration.interactions' => null]; + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $remove, 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); + + $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame([], $admin['errors']); + $stored = $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions']; + self::assertSame('data-probe', $stored[0]['attributeName']); + $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], $remove, 'attrs'); + self::assertSame([], $adminRemoval['errors']); + self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); + } + + /** + * The Lottie module of Divi 5.2.1 (excerpt of lottie/module.json: the animation source group) – + * the sample metadata has no Lottie module. + */ + private static function lottieWriter(bool $unfiltered): AttrWriter + { + $catalog = new ModuleCatalog([[ + 'name' => 'divi/lottie', + 'title' => 'Lottie', + 'category' => 'module', + 'attributes' => ['lottie' => ['type' => 'object', 'settings' => ['innerContent' => [ + 'groupType' => 'group-item', + 'item' => [ + 'attrName' => 'lottie.innerContent', + 'component' => ['type' => 'field', 'name' => 'divi/lottie'], + ], + ]]]], + ]]); + $cleaner = new ValueCleaner($catalog, ComposeFixture::references(), ComposeFixture::sanitizer($unfiltered)); + + return new AttrWriter($catalog, ComposeFixture::placeholders(), $cleaner); + } + + /** Ruling P19: the Lottie source only with unfiltered_html – set and removal. */ + public function testLottieSourceNeedsUnfilteredHtml(): void + { + $changes = ['lottie.innerContent' => ['desktop' => ['src' => 'https://probe.example/anim.json']]]; + $remove = ['lottie.innerContent' => null]; + $key = 'attrs.lottie.innerContent'; + + $editor = self::lottieWriter(false)->apply('divi/lottie', [], $changes, 'attrs'); + self::assertSame([], $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); + + $removal = self::lottieWriter(false)->apply('divi/lottie', [], $remove, 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); + + $admin = self::lottieWriter(true)->apply('divi/lottie', [], $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame( + 'https://probe.example/anim.json', + $admin['attrs']['lottie']['innerContent']['desktop']['value']['src'] + ); + $adminRemoval = self::lottieWriter(true)->apply('divi/lottie', $admin['attrs'], $remove, 'attrs'); + self::assertSame([], $adminRemoval['errors']); + self::assertArrayNotHasKey('lottie', $adminRemoval['attrs']); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index c25688e..9f1eb8c 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -6,13 +6,21 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class PlaceholdersTest extends TestCase { - /** @return array */ + /** + * Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode + * refuses colour references by design). + * + * @return array + */ public static function placeholders(): array { return [ @@ -31,20 +39,30 @@ final class PlaceholdersTest extends TestCase '{{field:telefon_mobil}}', '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', ], + 'internal link' => [ + '{{link:839}}', + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + true, + ], 'plain text' => ['Hallo Welt', 'Hallo Welt'], ]; } /** @dataProvider placeholders */ - public function testResolvesToDiviVariables(string $input, string $expected): void + public function testResolvesToDiviVariables(string $input, string $expected, bool $url = false): void { $placeholders = ComposeFixture::placeholders(); $resolved = $placeholders->resolve($input); self::assertSame($expected, $resolved); if ($expected !== $input) { - // What placeholders produce passes the strict checker unchanged (ruling S1/S25). - self::assertSame($resolved, ComposeFixture::references()->canonical($resolved, ComposeFixture::sanitizer())); + // What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live + // registry map knows post_link_url_page, the URL mode allows it. + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), $url)); } } @@ -60,6 +78,11 @@ final class PlaceholdersTest extends TestCase 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], 'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'], 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + 'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'], + 'link to a missing post' => ['{{link:999}}', 'Eintrag 999'], + 'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'], + 'link with a leading zero' => ['{{link:0839}}', 'Ungültige Beitrags-ID'], + 'link beyond PHP_INT_MAX' => ['{{link:9999999999999999999}}', 'Ungültige Beitrags-ID'], ]; } @@ -137,4 +160,12 @@ final class PlaceholdersTest extends TestCase ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) ); } + + public function testLinksNeedLinkTargets(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Eintrag 839'); + + (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}'); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 4ca6441..18f09ef 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -5,6 +5,12 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; +use LIT\MCP\Integrations\Divi\Schema\ShortformMap; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -352,5 +358,100 @@ final class ShortformWriterTest extends TestCase self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']); } } -} + /** E13a: {{link:ID}} becomes Divi's post_link_url_ in URL fields – and only there. */ + public function testInternalLinkOnlyInUrlFields(): void + { + $writer = self::linkWriter(); + + $button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n'); + self::assertSame([], $button['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + $button['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + + $text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']); + + // A post type the registry offers no link option for (fixture: page only): unknown name. + $post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n'); + self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']); + } + + /** + * The full form goes through the same choke point: URL fields only, no before/after, and the + * target must be readable (select_post, ruling S37 / preflight §4) – no path around ValueCleaner. + */ + public function testInternalLinkFullFormIsCheckedLikeThePlaceholder(): void + { + $writer = self::linkWriter(); + $link = static fn (string $settings): string => + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":' . $settings . '}})$'; + + $valid = $writer->apply('button', [], ['url' => $link('{"post_id":839}')], 'n'); + self::assertSame([], $valid['errors']); + self::assertSame($link('{"post_id":839}'), $valid['attrs']['button']['innerContent']['desktop']['value']['linkUrl']); + + $unreadable = $writer->apply('button', [], ['url' => $link('{"post_id":"666"}')], 'n'); + self::assertStringContainsString('lesbaren Eintrags', $unreadable['errors']['n.url']); + + $slug = $writer->apply('button', [], ['url' => $link('{"post_id":"kontakt"}')], 'n'); + self::assertStringContainsString('lesbaren Eintrags', $slug['errors']['n.url']); + + $extra = $writer->apply('button', [], ['url' => $link('{"post_id":"839","before":"x"}')], 'n'); + self::assertArrayHasKey('n.url', $extra['errors']); + self::assertSame([], $extra['attrs']); + + $inText = $writer->apply('heading', [], ['text' => $link('{"post_id":"839"}')], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $inText['errors']['n.text']); + + // A control-split placeholder is the placeholder (S35) and resolves the same way. + $split = $writer->apply('button', [], ['url' => '{' . chr(0) . '{link:839}}'], 'n'); + self::assertSame([], $split['errors']); + self::assertSame( + $link('{"post_id":"839"}'), + $split['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + } + + /** ShortformWriter with the live registry map (knows post_link_url_page) and the test link targets. */ + private static function linkWriter(): ShortformWriter + { + $design = DiviFixtures::designSystem(); + $cleaner = new ValueCleaner( + DiviFixtures::catalog(), + new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())), + ComposeFixture::sanitizer() + ); + + return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); + } + + /** Ruling P16: no curated short field reaches interactions, and a field that did would be gated. */ + public function testInteractionsAreBuilderOnlyInTheShortForm(): void + { + foreach (ShortformMap::types() as $type) { + foreach (ShortformMap::fields($type) as $name => $field) { + self::assertStringNotContainsString('interactions', $field['path'], $type . '.' . $name); + } + } + + $modules = ['raw' => [ + 'effects' => [ + 'path' => 'module.decoration.interactions', + 'sub' => 'interactions', + 'kind' => 'text', + 'description' => 'x', + ], + ]]; + foreach (['data-probe', null] as $value) { + $result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n'); + self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.effects']); + self::assertSame(self::BASE, $result['attrs']); + } + + $admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['effects' => 'x'], 'n'); + self::assertSame([], $admin['errors']); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php new file mode 100644 index 0000000..c82d1af --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -0,0 +1,470 @@ + that logs nothing). + */ +final class StoredNodeGuardTest extends TestCase +{ + private const PROBE_HTML = ''; + private const PROBE_SCRIPT = ''; + + private static function guard(bool $unfiltered = false): StoredNodeGuard + { + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + $sanitizer = ComposeFixture::sanitizer($unfiltered); + + $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); + + return new StoredNodeGuard($cleaner, $references, $sanitizer); + } + + private static function block(string $markup): Block + { + return (new Parser())->parse($markup)->children()[0]; + } + + /** + * A void block the way WordPress/Divi serialize it: quotes inside strings (so inside a + * $variable(…)$ payload) become " – ruling P2. + * + * @param array $attrs + */ + private static function node(string $name, array $attrs): string + { + return ''; + } + + /** @param array $settings */ + private static function ref(string $name, array $settings): string + { + return VariableRef::MARKER . json_encode( + ['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]], + JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ) . VariableRef::END; + } + + /** @return array */ + private static function desktop(mixed $value): array + { + return ['desktop' => ['value' => $value]]; + } + + private static function text(string $html): string + { + return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]); + } + + public function testBuilderContentWithInstancesAndLinksPasses(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + $link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']), + ])]])); + $title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => '']))); + + self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)'); + self::assertFalse($title->isOpaque()); + self::assertSame([], self::guard()->problems($index->find('s0'))); + self::assertSame([], self::guard()->problems($index->find('g0'))); + self::assertSame([], self::guard()->problems($link)); + self::assertSame([], self::guard()->problems($title)); + $rich = self::block(self::text('

Tom & Jerry

')); + self::assertSame([], self::guard()->problems($rich)); + } + + /** @return array */ + public static function libraryEntries(): array + { + return [ + 'global section' => ['stage3/library-global--globaler-abschnitt.html'], + 'global module' => ['stage3/library-global--globales-modul.html'], + 'full page layout' => ['stage3/library--layout-ganze-seite.html'], + 'section' => ['stage3/library--library-abschnitt.html'], + 'module' => ['stage3/library--library-modul.html'], + 'row' => ['stage3/library--library-zeile.html'], + ]; + } + + /** @dataProvider libraryEntries */ + public function testRealLibraryEntriesPass(string $fixture): void + { + $document = (new Parser())->parse(DiviFixtures::markup($fixture)); + + foreach ($document->children() as $root) { + self::assertSame([], self::guard()->problems($root)); + } + } + + /** @return array Markup, part of the problem, parsed opaque. */ + public static function refused(): array + { + $section = static fn (string $inner): string => '' + . '' + . $inner . ''; + + return [ + // P3: builder-only paths (S7/S26). + 'custom css' => [ + self::node('divi/text', ['css' => self::desktop(['freeForm' => 'body{display:none}'])]), + '"css"', + ], + 'custom css of a sub-element' => [ + self::node('divi/blurb', ['title' => ['css' => self::desktop(['freeForm' => 'a{}'])]]), + 'title.css', + ], + 'html attributes' => [ + self::node('divi/text', ['module' => ['decoration' => ['attributes' => self::desktop([ + 'attributes' => [['name' => 'data-probe', 'value' => '1']], + ])]]]), + 'module.decoration.attributes', + ], + 'own html before the module' => [ + self::node('divi/text', ['module' => ['advanced' => ['html' => self::desktop([ + 'htmlBefore' => self::PROBE_SCRIPT, + ])]]]), + 'module.advanced.html', + ], + 'own html below a state value' => [ + self::node('divi/text', ['module' => ['advanced' => self::desktop([ + 'html' => ['htmlBefore' => self::PROBE_SCRIPT], + ])]]), + 'module.advanced.html', + ], + 'interactions (P16)' => [ + self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([ + 'interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]], + ])]]]), + 'module.decoration.interactions', + ], + 'lottie source (P19)' => [ + self::node('divi/lottie', ['lottie' => ['innerContent' => self::desktop([ + 'src' => 'https://probe.example/anim.json', + 'trigger' => 'onLoad', + ])]]), + 'lottie.innerContent', + ], + // Code module (E6). + 'code module' => [ + self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), + 'Code-Modul', + ], + 'code module deep inside a section' => [$section(''), 'Code-Modul'], + // P3: non-Divi blocks and raw text parts. + 'core html block' => [ + '' . self::PROBE_HTML . '', + 'kein Divi-Block', + ], + 'core block inside a section' => [ + $section('

x

'), + 'kein Divi-Block', + ], + 'raw text between blocks' => [$section(self::PROBE_HTML), 'Freitext'], + 'divi 4 shortcode between blocks' => [$section('[et_pb_code]probe[/et_pb_code]'), 'Freitext'], + // P3: opaque blocks. + 'undecodable attributes' => ['', 'nicht lesbar', true], + 'global module reference on a plain block' => [ + self::node('divi/text', ['globalModule' => '5']), + 'nicht lesbar', + true, + ], + 'instance without block name' => [ + self::node('divi/global-layout', ['globalModule' => '5']), + 'nicht lesbar', + true, + ], + 'instance with local attributes that are no object' => [ + self::node( + 'divi/global-layout', + ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x'] + ), + 'nicht lesbar', + true, + ], + 'instance of a code module' => [ + self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/code']), + 'Code-Modul', + true, + ], + 'css in the local attributes of an instance' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['css' => self::desktop(['freeForm' => 'a{}'])], + ]), + '"css"', + true, + ], + 'rich text in the local attributes of an instance' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['content' => ['innerContent' => self::desktop(self::PROBE_HTML)]], + ]), + 'content.innerContent', + true, + ], + 'script in the snapshot of an instance' => [ + '' + . '' . self::text(self::PROBE_SCRIPT) + . '', + 'content.innerContent', + true, + ], + // P3: rich text that is not kses-stable. + 'probe element in rich text' => [self::text('

Hallo

' . self::PROBE_HTML), 'content.innerContent'], + 'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'], + 'script in a text field' => [ + self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), + 'title.innerContent', + ], + 'script url' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => 'javascript:void(0)', + ])]]), + 'button.innerContent', + ], + 'url the sanitizer would change' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'linkUrl' => 'https://kunde.test/"data-probe="1', + ])]]), + 'button.innerContent.linkUrl', + ], + 'odd key inside a value' => [ + self::node('divi/text', ['module' => ['decoration' => ['spacing' => self::desktop([ + '' => '1px', + ])]]]), + 'Schlüssel', + ], + 'instance with a non-numeric library id' => [ + self::node('divi/global-layout', ['globalModule' => '5x', 'blockName' => 'divi/text']), + 'nicht lesbar', + true, + ], + // Hardening (audit I1): Divi renders the first textual "globalModule":"" of the opener. + 'instance with two library ids (duplicate key)' => [ + '', + 'nicht lesbar', + true, + ], + 'instance with a second library id in its local attributes' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['module' => ['globalModule' => '7']], + ]), + 'nicht lesbar', + true, + ], + 'instance whose library id is escaped in the markup' => [ + '', + 'nicht lesbar', + true, + ], + 'instance with library id zero' => [ + self::node('divi/global-layout', ['globalModule' => '000', 'blockName' => 'divi/text']), + 'nicht lesbar', + true, + ], + 'control character' => [self::text('

a' . chr(1) . 'b

'), 'Steuerzeichen'], + // Stored $variable references: the same check as client references (VariableRef). + 'script in before' => [ + self::text(self::ref('post_title', ['before' => self::PROBE_SCRIPT, 'after' => ''])), + 'Divi-Verweis mit Einstellungen', + ], + 'probe html in after' => [ + self::text(self::ref('post_title', ['before' => '', 'after' => self::PROBE_HTML])), + 'Divi-Verweis mit Einstellungen', + ], + 'protected meta' => [self::text(self::ref('custom_meta__edit_lock', [])), 'custom_meta__edit_lock'], + 'enable_html' => [ + self::text(self::ref('post_meta_key', [ + 'before' => '', + 'select_meta_key' => 'custom_meta_telefon', + 'enable_html' => 'on', + ])), + 'enable_html', + ], + 'reference embedded in text' => [ + self::text('

Hallo ' . self::ref('post_title', []) . '

'), + 'allein im Wert', + ], + 'content reference as a link' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]), + ])]]), + 'button.innerContent', + ], + ]; + } + + /** Divi renders "0123" as entry 123 (intval) – such an instance is a readable reference. */ + public function testALibraryIdWithLeadingZerosIsAnInstance(): void + { + $block = self::block(self::node('divi/global-layout', ['globalModule' => '0123', 'blockName' => 'divi/text'])); + + self::assertSame([], self::guard()->problems($block)); + } + + /** @dataProvider refused */ + public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( + string $markup, + string $problem, + bool $opaque = false + ): void { + $block = self::block($markup); + self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)'); + + $problems = self::guard()->problems($block); + + self::assertNotSame([], $problems); + self::assertStringContainsString($problem, implode(' | ', $problems)); + self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it'); + } + + public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void + { + $markup = '' + . self::node('divi/code', []) . self::node('divi/code', []) + . self::text(self::PROBE_SCRIPT) + . ''; + $block = self::block($markup); + + $problems = self::guard()->problems($block); + + self::assertCount(2, $problems); + self::assertSame($markup, Serializer::block($block)); + } + + /** + * Like esc_url_raw() for an editor: only allowed protocols, characters outside its list go, and a + * value without scheme that does not start with "/", "#" or "?" gets "http://" (so url() changes + * relative links like "kontakt/"). $keepProtocolRelative decides what "//host" becomes. + */ + private static function wpLikeGuard(bool $keepProtocolRelative = true): StoredNodeGuard + { + $inner = ComposeFixture::sanitizer(); + $sanitizer = new class ($inner, $keepProtocolRelative) implements ValueSanitizer { + public function __construct(private ValueSanitizer $inner, private bool $keepProtocolRelative) + { + } + + public function text(string $value): string + { + return $this->inner->text($value); + } + + public function html(string $value): string + { + return $this->inner->html($value); + } + + public function code(string $value): string + { + return $this->inner->code($value); + } + + public function url(string $value): string + { + $allowed = '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i'; + $value = (string) preg_replace($allowed, '', trim($value)); + if (str_starts_with($value, '//')) { + return $this->keepProtocolRelative ? $value : 'http:' . $value; + } + if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $value, $scheme) === 1) { + return in_array(strtolower($scheme[1]), ['http', 'https', 'mailto', 'tel'], true) ? $value : ''; + } + + return preg_match('/^[\/#?]/', $value) === 1 ? $value : 'http://' . $value; + } + + public function allowsUnfilteredHtml(): bool + { + return false; + } + + public function canReadPost(int $id): bool + { + return $this->inner->canReadPost($id); + } + }; + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + + $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); + + return new StoredNodeGuard($cleaner, $references, $sanitizer); + } + + private static function link(string $url): Block + { + return self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => $url, + ])]])); + } + + /** Ruling P18: relative links stay copyable although esc_url_raw() would change them. */ + public function testRelativeLinksWithoutSchemePass(): void + { + $accepted = ['kontakt/', '/pfad', '#anker', '?q=1', 'https://kunde.test/a?b=1#c', 'mailto:info@kunde.test']; + foreach ($accepted as $url) { + self::assertSame([], self::wpLikeGuard()->problems(self::link($url)), $url); + } + } + + /** Ruling P18: every value with a scheme must survive url() unchanged; odd relative values are refused. */ + public function testLinksWithASchemeOrOddCharactersAreRefused(): void + { + $refused = [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'java' . chr(9) . 'script:alert(1)', + 'data:text/html,probe', + 'kontakt/"data-probe="1', + "kontakt/'x", + 'kontakt/ x', + 'kontakt/', + '\\evil.example', + '/\\evil.example', + ]; + foreach ($refused as $url) { + self::assertFalse(self::link($url)->isOpaque(), $url); + self::assertNotSame([], self::wpLikeGuard()->problems(self::link($url)), $url); + } + + // Protocol-relative: only when url() leaves it as it is. + self::assertSame([], self::wpLikeGuard()->problems(self::link('//evil.example'))); + self::assertNotSame([], self::wpLikeGuard(false)->problems(self::link('//evil.example'))); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php index c0da09e..ba38032 100644 --- a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; @@ -201,4 +202,22 @@ final class TreeEditorTest extends TestCase self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); } + + /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */ + public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void + { + $document = $this->document('stage3/page--globale-instanzen.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1']))); + + $index = NodeIndex::build($document); + $module = GlobalInstance::of(Block::create('divi/text'), 1059); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module])); + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)]) + ); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 706e1fd..7543687 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -198,4 +198,59 @@ final class ValueCleanerTest extends TestCase self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test')); self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x')); } + + /** + * Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie, + * Preset) are builder-only like HTML attributes, at every depth. + */ + public function testInteractionPathsNeedUnfilteredHtml(): void + { + $value = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $paths = [ + 'module.decoration.interactions', + 'module.decoration.interactions.desktop', + 'title.decoration.interactions', + 'decoration.interactions', + ]; + foreach ($paths as $path) { + try { + ComposeFixture::cleaner()->clean('divi/text', $path, $value); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value)); + } + + // Only a whole "interactions" segment below "decoration" counts. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x')); + } + + /** Ruling P19: the Lottie source (lottie-web runs expressions with eval) is builder-only, at every depth. */ + public function testLottieSourceNeedsUnfilteredHtml(): void + { + $value = ['src' => 'https://probe.example/anim.json', 'loop' => 'on']; + $paths = ['lottie.innerContent', 'lottie.innerContent.desktop', 'lottie.innerContent.desktop.value.src']; + foreach ($paths as $path) { + try { + ComposeFixture::cleaner()->clean('divi/lottie', $path, $value); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/lottie', $path, $value)); + } + + // The size of the animation and look-alike paths stay open. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.advanced.width', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', 'x')); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 770d110..c2d86de 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -421,11 +421,11 @@ final class VariableRefTest extends TestCase ); self::assertSame( self::content('post_link_url_page', '{"post_id":"12"}'), - $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer) + $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true) ); self::assertSame( self::content('post_link_url_page', '{"post_id":12}'), - $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer) + $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true) ); $rejected = [ @@ -440,12 +440,25 @@ final class VariableRefTest extends TestCase ]; foreach ($rejected as $case => [$value, $fragment]) { try { - $ref->canonical($value, $sanitizer); + // URL mode: internal links (post_link_url_*) are allowed only there (E13a). + $ref->canonical($value, $sanitizer, true); self::fail('Accepted: ' . $case); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage(), $case); } } } -} + /** E13a: an internal link (post_link_url_) is a URL – refused in every other field. */ + public function testInternalLinksOnlyInUrlFields(): void + { + $ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]); + $link = self::content('post_link_url_page', '{"post_id":"12"}'); + + self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('nur in Link- und URL-Feldern'); + $ref->canonical($link, ComposeFixture::sanitizer()); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php index 6ef4d0e..c91a888 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Tests\Support\DiviFixtures; @@ -41,4 +42,42 @@ final class DiviDesignSystemAbilityTest extends TestCase self::assertToolError($result, 'forbidden'); } + + public function testFiltersPartsAndPresetsOfOneModule(): void + { + Functions\when('current_user_can')->justReturn(true); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); + $ability = new DiviDesignSystemAbility($reader); + + $result = $ability->execute(['only' => ['presets'], 'module' => 'button']); + + self::assertSame([], $result['colors']); + self::assertSame([], $result['variables']); + self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id')); + self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module')))); + self::assertNotEmpty($result['fonts']['heading']); + self::assertContains('Nicht angefordert: colors, variables.', $result['notes']); + } + + public function testCapsThePresetList(): void + { + Functions\when('current_user_can')->justReturn(true); + $items = []; + for ($i = 0; $i < 250; ++$i) { + $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text']; + } + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn($design); + + $result = (new DiviDesignSystemAbility($reader))->execute([]); + + self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); + self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + + $colors = (new DiviDesignSystemAbility($reader))->execute(['only' => ['colors']]); + self::assertSame([], $colors['presets']); + self::assertStringNotContainsString('Presets gezeigt', implode(' ', $colors['notes']), 'not requested'); + } } diff --git a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php new file mode 100644 index 0000000..cb3aae4 --- /dev/null +++ b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php @@ -0,0 +1,44 @@ + static fn (): bool => true, + 'B' => static fn (): bool => false, + 'C' => static function (): bool { + throw new RuntimeException('Divi changed'); + }, + ]); + + self::assertSame(['B', 'C'], $compatibility->problems()); + } + + public function testDefaultsCoverTheDiviInternalsInUse(): void + { + self::assertSame( + [ + 'ModuleRegistration (Modul-Katalog)', + 'DynamicContentOptions (Dynamic Content)', + 'et_pb_is_allowed (Divi-Rollen)', + 'Post-Typ et_pb_layout (Bibliothek)', + 'Taxonomien scope, layout_type, module_width (Bibliothek)', + 'GlobalLayout::is_global_layout_template (globale Module)', + 'ET_Core_PageResource (CSS-Cache)', + 'et_theme_builder_clear_wp_cache (Theme-Builder-Cache)', + 'Block-Typ divi/global-layout (Instanzen globaler Module)', + 'Taxonomien layout_category, layout_tag (Bibliothek)', + ], + array_keys(DiviCompatibility::defaults()) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 97bdb75..1d62b49 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; +use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WP_Error; final class DiviIntegrationTest extends TestCase { @@ -26,6 +29,8 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-delete', 'lit/divi-duplicate-node', 'lit/divi-replace-text', + 'lit/divi-node-copy', + 'lit/divi-dynamic-sources', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void @@ -46,6 +51,43 @@ final class DiviIntegrationTest extends TestCase } } + public function testProvidesTheLibraryGroup(): void + { + Functions\when('__')->returnArg(); + $library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library(); + + self::assertSame('divi-library', $library->id()); + self::assertSame('Divi-Bibliothek', $library->label()); + self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); + self::assertSame( + ['lit/library-list', 'lit/library-save', 'lit/library-insert'], + array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) + ); + foreach ($library->abilities() as $ability) { + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + self::assertNotNull($ability->outputSchema(), $ability->name()); + } + } + + /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */ + public function testNodeCopyFollowsLitDiviInsert(): void + { + $integration = new DiviIntegration( + Mockery::mock(ContentSchema::class), + FakeChangeLog::forbidden(), + diviWritable: static fn (): bool => false + ); + $copy = null; + foreach ($integration->abilities() as $ability) { + if ($ability->name() === 'lit/divi-node-copy') { + $copy = $ability; + } + } + + self::assertNotNull($copy); + self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported'); + } + public function testGivesContentCreateTheDiviShortForm(): void { $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); @@ -55,4 +97,30 @@ final class DiviIntegrationTest extends TestCase self::assertInstanceOf(DiviContentComposer::class, $composer); self::assertFalse($composer->available(), 'no Divi classes in unit tests'); } + + /** E1/E3: the Divi read tools reach library entries; ruling P9: a hidden library leaves them not found. */ + public function testTheReadToolsReachTheLibraryUnlessItIsHidden(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + $outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]); + + // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12); + // the wiring shows in which gate the integration asks. + $asked = 0; + $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool { + ++$asked; + + return true; + }); + self::assertToolError($outline($visible), 'not_found'); + self::assertSame(1, $asked); + self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found'); + + $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false); + $refusal = $outline($hidden); + self::assertInstanceOf(WP_Error::class, $refusal); + self::assertToolError($refusal, 'not_found'); + } } diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php new file mode 100644 index 0000000..a14d665 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -0,0 +1,249 @@ + [ + 'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + 'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])], + ], + 1055 => [ + 'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + ], + 1057 => [], + ]; + Functions\when('get_the_terms')->alias( + static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : '' + ); + } + + public function testGlobalScopeAndLayoutTypeFromTheTerms(): void + { + $store = new DiviLibraryStore(); + + self::assertTrue($store->isGlobal(1058)); + self::assertFalse($store->isGlobal(1055)); + self::assertSame('section', $store->layoutType(1058)); + self::assertNull($store->layoutType(1057)); + self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category')); + } + + public function testDescribesAnEntry(): void + { + $post = new WP_Post([ + 'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul', + 'post_modified_gmt' => '2026-09-26 20:56:29', + ]); + + self::assertSame( + [ + 'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal', + 'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29', + ], + (new DiviLibraryStore())->describe($post) + ); + } + + public function testFindQueriesPublishedEntriesWithFilters(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find( + ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'], + 20, + 40 + ); + + self::assertSame('et_pb_layout', $captured['post_type']); + self::assertSame('publish', $captured['post_status']); + self::assertSame(21, $captured['posts_per_page']); + self::assertSame(40, $captured['offset']); + self::assertSame('Kopf', $captured['s']); + self::assertSame(['post_title'], $captured['search_columns'], 'audit L3: the title only'); + self::assertArrayNotHasKey('author', $captured); + self::assertSame( + [ + 'relation' => 'AND', + ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']], + ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'], + ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']], + ], + $captured['tax_query'] + ); + } + + /** Audit L3: users without edit_others_posts query their own entries only. */ + public function testFindRestrictsToAnAuthor(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find(['author' => 12], 20, 0); + + self::assertSame(12, $captured['author']); + } + + public function testReportsMissingTaxonomies(): void + { + Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); + + self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); + } + + private static function draft(?FakeChangeLog $log = null): NewDraft + { + return new NewDraft(null, $log ?? FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag'); + } + + public function testCreatesAPublishedEntryWithTermsAndMeta(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + $inserted = []; + Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int { + $inserted = $data; + + return 2001; + }); + $terms = []; + Functions\when('wp_set_object_terms')->alias( + static function (int $id, array $values, string $taxonomy) use (&$terms): array { + $terms[$taxonomy] = $values; + + return [1]; + } + ); + $meta = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool { + $meta[$key] = $value; + + return true; + }); + // By reference: an arrow function would capture the still empty $meta. + Functions\when('get_post_meta')->alias(static function (int $id, string $key) use (&$meta): string { + return $meta[$key] ?? ''; + }); + $spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page'); + + self::assertSame(2001, (new DiviLibraryStore())->create($spec, self::draft())); + self::assertSame( + ['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''], + $inserted + ); + self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms); + self::assertSame('[]', $meta['_et_pb_excluded_global_options']); + } + + public function testAFailedStepDeletesTheNewEntry(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.')); + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001])); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + /** Ruling P6: a failed rollback is an honest partial write, logged as partial (NewDraft). */ + public function testAFailedRollbackIsAPartialWrite(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn([1]); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log)); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + } + $log->assertPartials([ + 'ability' => 'lit/library-save', + 'object_type' => 'et_pb_layout', + 'object_id' => '2001', + 'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen', + ]); + } + + /** Ruling P6: a throwing insert hook leaves the entry's existence unknown – partial write without id. */ + public function testAThrowingInsertHookIsAPartialWrite(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->alias(static function (): never { + throw new \RuntimeException('hook kaputt'); + }); + $log = FakeChangeLog::partialsOnly(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log)); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertNull($e->postId); + } + self::assertCount(1, $log->partials); + } + + public function testARefusedInsertCreatesNothing(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(new \WP_Error('db_insert_error', 'Datenbankfehler.')); + Functions\expect('wp_delete_post')->never(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void + { + Functions\when('taxonomy_exists')->justReturn(false); + Functions\expect('wp_insert_post')->never(); + + $this->expectException(WriteFailed::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php new file mode 100644 index 0000000..876e5cf --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php @@ -0,0 +1,29 @@ + $cap !== 'edit_global_library'); + + self::assertTrue($roles->allows('divi_library')); + self::assertNull($roles->refusal('divi_library')); + $refusal = $roles->refusal('edit_global_library'); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message()); + self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message()); + } + + public function testWithoutDiviNothingIsAllowed(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new DiviRoles())->allows('divi_library')); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php new file mode 100644 index 0000000..ade02b3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -0,0 +1,567 @@ + Content and scope handed to the saver. */ + private array $saved = []; + + private int $backups = 0; + + /** Change log of the writer; null = DiviWriterFixture::log() (records only). */ + private ?FakeChangeLog $log = null; + + /** Thrown by the saver instead of saving, if set. */ + private ?\Throwable $failure = null; + + /** @var list Live (published, private, scheduled) posts that show the global entry (ruling P8a). */ + private array $live = [1053]; + + private bool $trash = true; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_revisions_enabled')->justReturn(true); + Functions\when('_prime_post_caches')->justReturn(null); + } + + /** @var list Normal (non-global) entries that pages nevertheless show (audit L2). */ + private array $referenced = []; + + /** + * For the global entries and $this->referenced: the live posts ($this->live; 1053 published, the + * others private) and draft 1070. Other entries are shown nowhere. + */ + private function usages(): GlobalUsages + { + return LibraryFixtures::usages(fn (int $id): array => ! LibraryFixtures::ITEMS[$id][2] + && ! in_array($id, $this->referenced, true) ? [] : array_merge( + array_map( + static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'], + $this->live + ), + [['id' => 1070, 'status' => 'draft', 'type' => 'page']] + )); + } + + /** @param list $deniedRoles */ + private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + if ($this->failure !== null) { + throw $this->failure; + } + $this->saved[] = [$content, $scope]; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $access = LibraryFixtures::access($deniedRoles, $writable); + $design = DiviWriterFixture::designReader(); + $globals = new GlobalEdits( + $access, + $this->usages(), + function (WP_Post $item): int { + ++$this->backups; + + return 1071; + }, + trashEnabled: fn (): bool => $this->trash + ); + + return new DiviWriter( + LibraryFixtures::loader($access), + $saver, + $this->log ?? DiviWriterFixture::log(), + $design, + DiviWriterFixture::composition($design), + $globals + ); + } + + private static function rev(int $id): string + { + return Rev::of((string) LibraryFixtures::item($id)->post_content); + } + + /** @return array */ + private static function relabel(int $id, bool $confirm = false): array + { + return array_merge( + ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]], + $confirm ? ['confirm' => true] : [] + ); + } + + /** A user with every capability but $cap on the posts $ids. */ + private static function denyOn(string $cap, array $ids): void + { + Functions\when('current_user_can')->alias( + static fn (string $asked, mixed ...$args): bool => ! ($asked === $cap + && ($ids === [] || in_array((int) ($args[0] ?? 0), $ids, true))) + ); + } + + public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + + $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + + self::assertTrue($result['ok']); + self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]); + self::assertStringStartsWith('', + 1054 => '[et_pb_section global_module="01058"][/et_pb_section]', + 1055 => '', + 1056 => '', + 1057 => '', + 1061 => '', + ]; + $db = Mockery::mock(\wpdb::class)->makePartial(); + $queries = []; + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries, $contents): array { + $queries[] = $sql; + if (str_contains($sql, 'post_content FROM')) { + preg_match('/IN \(([0-9, ]+)\)/', $sql, $in); + $ids = array_map('intval', explode(',', $in[1])); + + return array_map(static fn (int $id): array => ['ID' => (string) $id, 'post_content' => $contents[$id]], $ids); + } + + return array_map( + static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'publish', 'post_type' => 'page'], + array_keys($contents) + ); + }); + + $rows = (new GlobalUsages($db))->rows(1058); + + self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed'); + self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); + self::assertStringNotContainsString('post_content,', $queries[0]); + // Ruling P24: case-insensitive whatever the column collation. + self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]); + self::assertStringContainsString("LOWER(post_content) LIKE '%global" . $bs . "_module%'", $queries[0]); + self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]); + self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); + self::assertStringContainsString("post_type <> 'revision'", $queries[0]); + self::assertCount(2, $queries, 'one candidate query, one content chunk'); + } + + /** Audit L1: contents are read in chunks; beyond the parse cap candidates count as references. */ + public function testReadsContentsInChunksAndFailsClosedBeyondTheCap(): void + { + $total = GlobalUsages::PARSE_CAP + 3; + $db = Mockery::mock(\wpdb::class)->makePartial(); + $chunks = 0; + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$chunks, $total): array { + if (str_contains($sql, 'post_content FROM')) { + ++$chunks; + preg_match('/IN \(([0-9, ]+)\)/', $sql, $in); + + return array_map( + static fn (string $id): array => ['ID' => trim($id), 'post_content' => 'nichts'], + explode(',', $in[1]) + ); + } + + return array_map( + static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'draft', 'post_type' => 'page'], + range(1, $total) + ); + }); + + $rows = (new GlobalUsages($db))->rows(1058); + + self::assertSame((int) ceil(GlobalUsages::PARSE_CAP / GlobalUsages::CONTENT_CHUNK), $chunks); + self::assertSame(range(GlobalUsages::PARSE_CAP + 1, $total), array_column($rows, 'id')); + } + + /** Audit L4: readable posts with title, unreadable ones only counted. */ + public function testListsReadablePostsAndCountsTheOthers(): void + { + $usages = LibraryFixtures::usages([ + ['id' => 1053, 'status' => 'draft', 'type' => 'page'], + ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], + ]); + Functions\when('get_post')->alias( + static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_title' => 'Titel ' . $id]) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $id !== 1061 + ); + + $report = $usages->report(1058); + + self::assertSame(2, $report->count); + self::assertSame( + [['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'draft', 'type' => 'page']], + $report->items + ); + self::assertSame(1, $report->hidden); + self::assertSame(1, $report->live); + self::assertSame([], $report->blocked); + self::assertSame(1, $report->blockedHidden); + self::assertSame( + ['affected' => $report->items, 'affected_count' => 2, 'hidden_count' => 1, 'blocked_hidden_count' => 1], + $report->details() + ); + } + + /** Audit L1: rights are checked in chunks, each with its own primed cache that is dropped afterwards. */ + public function testChecksLargeSetsInChunksAndReleasesTheCache(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, 450))); + $primed = []; + Functions\when('_prime_post_caches')->alias(static function (array $ids) use (&$primed): void { + $primed[] = count($ids); + }); + $deleted = 0; + Functions\when('wp_cache_delete')->alias(static function () use (&$deleted): bool { + ++$deleted; + + return true; + }); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id % 100 !== 0); + + $report = $usages->report(1058); + + self::assertSame([GlobalUsages::CHUNK, GlobalUsages::CHUNK, 50], $primed); + self::assertSame(450, $deleted); + self::assertSame(450, $report->count); + self::assertCount(GlobalUsages::LIMIT, $report->items); + self::assertSame(4, $report->hidden); + self::assertSame(4, $report->blockedHidden); + self::assertTrue($report->blocks()); + } + + /** Audit L1: above the hard cap nothing is checked for edit_post, and the report blocks. */ + public function testAboveTheHardCapNoPostIsCheckedForEditing(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, GlobalUsages::HARD_CAP + 1))); + Functions\when('get_post')->justReturn(null); + $asked = []; + Functions\when('current_user_can')->alias(static function (string $cap) use (&$asked): bool { + $asked[$cap] = ($asked[$cap] ?? 0) + 1; + + return true; + }); + + $report = $usages->report(1058); + + self::assertTrue($report->tooMany); + self::assertTrue($report->blocks()); + self::assertArrayNotHasKey('edit_post', $asked); + self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']); + } + + /** Audit L6: Theme Builder layouts additionally need Divi's role option theme_builder. */ + public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void + { + $rows = [ + ['id' => 1053, 'status' => 'publish', 'type' => 'page'], + ['id' => 1061, 'status' => 'publish', 'type' => 'et_header_layout'], + ['id' => 1062, 'status' => 'publish', 'type' => 'et_body_layout'], + ['id' => 1063, 'status' => 'publish', 'type' => 'et_footer_layout'], + ['id' => 1064, 'status' => 'publish', 'type' => 'et_template'], + ]; + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->justReturn(true); + + $without = LibraryFixtures::usages($rows, LibraryFixtures::roles(['theme_builder']))->report(1058); + $with = LibraryFixtures::usages($rows, LibraryFixtures::roles())->report(1058); + + self::assertSame([1061, 1062, 1063, 1064], $without->blocked); + self::assertSame([], $with->blocked); + } + + public function testAListingSkipsTheEditChecks(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post'); + + $report = $usages->report(1058, false); + + self::assertFalse($report->blocks()); + self::assertSame(['affected', 'affected_count'], array_keys($report->listing())); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php new file mode 100644 index 0000000..3044039 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -0,0 +1,183 @@ +item(1055, false)); + self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business'); + self::assertNull($access->item(9999, false)); + self::assertNull($access->item('abc', false)); + self::assertNull($access->canList()); + } + + /** Ruling P12: without the library right at all, library IDs read like missing IDs (no type oracle). */ + public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void + { + $item = LibraryFixtures::item(1055); + + LibraryFixtures::given([$item], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access()->canList(), 'forbidden'); + + LibraryFixtures::given([$item]); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canList(), 'forbidden'); + + LibraryFixtures::given([$item], ['edit_post']); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message()); + } + + public function testTrashedItemsCanBeRead(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'trash']); + LibraryFixtures::given([$item]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false)); + } + + public function testGlobalFromTheStore(): void + { + $access = LibraryFixtures::access(); + + self::assertTrue($access->isGlobal(LibraryFixtures::item(1058))); + self::assertFalse($access->isGlobal(LibraryFixtures::item(1055))); + } + + public function testRefusalsNameNeitherTitleNorContent(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'private', 'post_title' => 'Geheimer Entwurf']); + + foreach ([['edit_posts'], ['edit_post']] as $denied) { + LibraryFixtures::given([$item], $denied); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, $denied === ['edit_post'] ? 'forbidden' : 'not_found'); + self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); + self::assertStringNotContainsString('Geheimer Entwurf', (string) wp_json_encode($refusal->get_error_data())); + } + } + + /** Ruling P9: with the group "Divi-Bibliothek" disabled, library IDs read like IDs that do not exist. */ + public function testAHiddenLibraryAnswersNotFoundLikeAMissingEntry(): void + { + $item = LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf']); + LibraryFixtures::given([$item, LibraryFixtures::page('')], ['edit_post']); + $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); + + foreach ([false, true] as $forWrite) { + $refusal = $hidden->item(1055, $forWrite); + self::assertToolError($refusal, 'not_found'); + self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); + self::assertStringNotContainsString('Bibliothek', $refusal->get_error_message()); + } + self::assertNull($hidden->item(LibraryFixtures::PAGE, false), 'pages stay PostGuard\'s business'); + self::assertNull($hidden->item(9999, false)); + } + + /** Ruling P12: the refusal without the library right is byte-identical to a missing ID's. */ + public function testWithoutTheLibraryRightAnEntryIsIndistinguishableFromAMissingId(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + + foreach ([[['edit_posts'], []], [[], ['divi_library']]] as [$denied, $deniedRoles]) { + LibraryFixtures::given([LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf'])], $denied); + $refusal = LibraryFixtures::access($deniedRoles)->item(1055, false); + + LibraryFixtures::given([], $denied); + $missing = PostGuard::editable(1055, $schema, 'post_id', true); + + self::assertInstanceOf(WP_Error::class, $refusal); + self::assertInstanceOf(WP_Error::class, $missing); + self::assertSame($missing->get_error_code(), $refusal->get_error_code()); + self::assertSame($missing->get_error_message(), $refusal->get_error_message()); + self::assertSame($missing->get_error_data(), $refusal->get_error_data()); + } + } + + public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void + { + LibraryFixtures::given([ + LibraryFixtures::item(1057), + LibraryFixtures::item(1059), + LibraryFixtures::item(1055, ['post_status' => 'trash']), + ]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true)); + self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden'); + $readonly = LibraryFixtures::access([], false)->item(1057, true); + self::assertToolError($readonly, 'forbidden'); + self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message()); + self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true)); + self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true)); + } + + public function testEditingNeedsEditPostOnTheEntryAndReadOnlyStillReads(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)], ['edit_post']); + self::assertToolError(LibraryFixtures::access()->item(1057, true), 'forbidden'); + + LibraryFixtures::given([LibraryFixtures::item(1057)]); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false)); + self::assertNull(LibraryFixtures::access()->writableGate()); + } + + public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void + { + LibraryFixtures::given([]); + self::assertNull(LibraryFixtures::access()->canSave()); + + LibraryFixtures::given([], ['publish_posts']); + self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden'); + + LibraryFixtures::given([]); + self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canSave(), 'forbidden'); + } + + /** Ruling P7: a global entry additionally needs Divi's edit_global_library. */ + public function testSavingAGlobalEntryNeedsEditGlobalLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access(['edit_global_library'])->canSave()); + self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden'); + self::assertNull(LibraryFixtures::access()->canSave(true)); + } + + /** Inserting from the library (E3): canList(), Divi's add_library and a group that is not read-only. */ + public function testInsertingNeedsAddLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access()->canInsert()); + self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canInsert(), 'forbidden'); + + LibraryFixtures::given([], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden'); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php new file mode 100644 index 0000000..613ce6e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php @@ -0,0 +1,269 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + LibraryFixtures::given(array_merge( + [$this->page], + array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)) + )); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $diviWritable = true, bool $unfiltered = false): LibraryInsertAbility + { + $access = LibraryFixtures::access($deniedRoles); + $failures = []; + + return new LibraryInsertAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, LibraryFixtures::loader($access)), + $access, + static fn (): bool => $diviWritable + ); + } + + /** + * @param array $extra + * @return array + */ + private function input(array $extra): array + { + return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra); + } + + private static function fixtureInstance(int $id, string $end): string + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''), $this->saved[0]); + self::assertSame('g0', $result['changed'][0]['id']); + self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings'])); + } + + public function testInsertsAGlobalModuleIntoAColumn(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]); + } + + public function testUntouchedContentKeepsItsBytes(): void + { + $before = (string) $this->page->post_content; + + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + $instance = self::fixtureInstance(1059, '/-->'); + $at = (int) strpos($this->saved[0], $instance); + self::assertSame($before, substr($this->saved[0], 0, $at) . substr($this->saved[0], $at + strlen($instance))); + } + + public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1055])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString("\r", $this->saved[0]); + } + + public function testLayoutCopyInsertsEverySection(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy'])); + + self::assertCount(3, $result['changed']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + /** F10: a copy of a global entry is deglobalized like Divi's /load – its root bytes, no globalParent. */ + public function testCopyOfAGlobalEntryIsItsPlainRoot(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString('globalParent', $this->saved[0]); + self::assertStringNotContainsString('global-layout', $this->saved[0]); + self::assertSame([], $result['warnings']); + } + + /** F10: nested instances in a (stored) global entry become plain nodes in the copy. */ + public function testCopyDeglobalizesNestedInstances(): void + { + $instance = self::fixtureInstance(1059, '/-->'); + $this->givenItem(1058, '' + . '' + . $instance . '' + . ''); + + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + self::assertTrue($result['ok']); + self::assertStringNotContainsString('globalModule', $this->saved[0]); + self::assertStringContainsString(''); + foreach ( + [ + '' . $instance . '', + '' + . '' + . $instance . '' + . '', + '', + '', + ] as $content + ) { + $this->givenItem(1059, $content); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1059])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Verschachtelung', $result->get_error_message()); + } + self::assertSame([], $this->saved); + } + + public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void + { + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'link'])), 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testATrashedEntryIsNotFound(): void + { + $this->givenItem(1055, DiviFixtures::markup('stage3/library--library-abschnitt.html'), ['post_status' => 'trash']); + + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055])), 'not_found'); + self::assertSame([], $this->saved); + } + + /** Ruling P5: the entry is read through LibraryAccess – edit_post on it, not read_post. */ + public function testNeedsEditPostOnTheEntry(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && (int) ($args[0] ?? 0) === 1058) + ); + + $result = $this->ability()->execute($this->input(['library_id' => 1058])); + + self::assertToolError($result, 'forbidden'); + self::assertStringNotContainsString('[Fixture]', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testNeedsAddLibraryAndAWritableDiviGroup(): void + { + self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability(['add_library'])->execute($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported'); + self::assertToolError($this->ability([], false)->execute($this->input(['library_id' => 1058])), 'unsupported'); + self::assertTrue($this->ability()->permission($this->input(['library_id' => 1058]))); + self::assertTrue($this->ability()->isDirect()); + self::assertSame([], $this->saved); + } + + public function testTheTargetKeepsTheTreeRevLockAndStructureRules(): void + { + self::assertToolError( + $this->ability()->execute(['post_id' => 7, 'tree_rev' => 'alt', 'library_id' => 1058]), + 'rev_mismatch' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1058, 'parent' => 's0.r0.c1'])), + 'validation_failed' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's9'])), + 'validation_failed' + ); + self::assertSame([], $this->saved); + } + + public function testEditorCannotInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability()->execute($this->input(['library_id' => 1080])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAdminMayInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1080])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php new file mode 100644 index 0000000..44d2b20 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php @@ -0,0 +1,113 @@ +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))) + ->find($id); + } + + private static function libraryRoot(string $file): Block + { + return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0]; + } + + /** @return array Fixture role => [node source, global]. */ + public static function builderEntries(): array + { + return [ + 'library--library-abschnitt' => ['page:s1', false], + 'library--library-zeile' => ['page:s1.r0', false], + 'library--library-modul' => ['page:s1.r0.c0.m0', false], + 'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true], + 'library-global--globales-modul' => ['library:library-global--globales-modul.html', true], + ]; + } + + /** + * Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry), + * without Divi's broken _et_pb_row_layout ("[object Object]", finding 5). + * + * @dataProvider builderEntries + */ + public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void + { + [$kind, $ref] = explode(':', $source, 2); + $node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref); + $role = (string) $this->dataName(); + $json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true); + + $spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page'); + + self::assertInstanceOf(LibraryItemSpec::class, $spec); + $expectedTerms = []; + foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) { + $expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug'); + } + self::assertEquals($expectedTerms, $spec->terms()); + $expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']); + unset($expectedMeta['_et_pb_row_layout']); + self::assertEquals($expectedMeta, $spec->meta()); + } + + public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void + { + $catalog = DiviFixtures::catalog(); + + self::assertStringContainsString( + 'bereits ein globales Modul (Bibliothekseintrag 1058)', + (string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'nur Abschnitte, Zeilen und Module', + (string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page') + ); + self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page')); + } + + /** Ruling P11: opaque roots and instances without a readable id are refused before wrapping. */ + public function testRefusesOpaqueRootsAndUnreadableInstances(): void + { + $catalog = DiviFixtures::catalog(); + $document = (new Parser())->parse( + '' + . '' + ); + [$opaque, $instance] = $document->children(); + self::assertTrue($opaque->isOpaque()); + + self::assertStringContainsString('nicht lesbar', (string) LibraryItemSpec::forNode($opaque, $catalog, 'T', false, 'page')); + self::assertStringContainsString( + 'globales Modul', + (string) LibraryItemSpec::forNode($instance, $catalog, 'T', false, 'page') + ); + } + + public function testFullwidthAndTerms(): void + { + $section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]); + + $spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page'); + + self::assertSame(['fullwidth'], $spec->terms()['module_width']); + self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']); + self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php new file mode 100644 index 0000000..7cddffb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -0,0 +1,284 @@ +justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + } + + /** + * @param list $deniedRoles + * @param list $ids Entries the store lists (in this order). + */ + private function ability(array $deniedRoles = [], array $ids = [1055, 1058, 1059]): LibraryListAbility + { + $store = LibraryFixtures::store(); + $store->allows('find')->andReturnUsing(static fn (array $filters, int $limit, int $offset): array => array_slice( + array_map(static fn (int $id) => LibraryFixtures::item($id), $ids), + $offset, + $limit + 1 + )); + $access = LibraryFixtures::access($deniedRoles, true, $store); + $usages = LibraryFixtures::usages( + static fn (int $id): array => in_array($id, [1058, 1056], true) + ? [['id' => 1053, 'status' => 'draft', 'type' => 'page']] + : [] + ); + + return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); + } + + /** A user with every capability but edit_post on the entries $hidden. */ + private static function withoutEditPostOn(array $hidden): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array((int) ($args[0] ?? 0), $hidden, true)) + ); + } + + public function testListsAPageWithCursor(): void + { + LibraryFixtures::given([]); + + $first = $this->ability()->execute(['limit' => 2]); + $second = $this->ability()->execute(['limit' => 2, 'cursor' => $first['next_cursor']]); + + self::assertSame([1055, 1058], array_column($first['items'], 'id')); + self::assertSame(['normal', 'global'], array_column($first['items'], 'scope')); + self::assertSame('section', $first['items'][0]['layout_type']); + self::assertSame(Cursor::encode(2), $first['next_cursor']); + self::assertSame([1059], array_column($second['items'], 'id')); + self::assertNull($second['next_cursor']); + } + + public function testPassesTheFilters(): void + { + LibraryFixtures::given([]); + $store = LibraryFixtures::store(); + $store->expects('find')->with(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten'], 20, 0) + ->andReturn([]); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + Mockery::mock(GlobalUsages::class) + ); + + $result = $ability->execute(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten', 'category' => '']); + + self::assertSame(['items' => [], 'next_cursor' => null], $result); + } + + public function testDetailOfAGlobalEntryWithOutlineAndAffectedPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $result = $this->ability()->execute(['id' => 1058]); + + self::assertSame(1058, $result['items'][0]['id']); + self::assertNull($result['next_cursor']); + self::assertStringStartsWith('s0 section "Global Abschnitt"', $result['outline']); + self::assertSame(12, strlen($result['tree_rev'])); + self::assertSame(1, $result['affected_count']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $result['edit_url']); + } + + public function testDetailOfSomethingElseIsNotFoundAndTheRoleIsNeeded(): void + { + LibraryFixtures::given([LibraryFixtures::page('')]); + + self::assertToolError($this->ability()->execute(['id' => LibraryFixtures::PAGE]), 'not_found'); + self::assertToolError($this->ability(['divi_library'])->permission([]), 'forbidden'); + self::assertToolError($this->ability()->execute(['cursor' => '!!']), 'validation_failed'); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertFalse($this->ability()->isDirect()); + } + + /** Output ruling: entries without edit_post are left out – no title, no count, the cursor still pages right. */ + public function testLeavesOutEntriesTheUserMayNotEdit(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1055, 1057]); + $ids = [1054, 1055, 1056, 1057, 1058, 1059]; + + $first = $this->ability([], $ids)->execute(['limit' => 2]); + $second = $this->ability([], $ids)->execute(['limit' => 2, 'cursor' => $first['next_cursor']]); + + self::assertSame([1054, 1056], array_column($first['items'], 'id')); + self::assertNotNull($first['next_cursor']); + self::assertSame([1058, 1059], array_column($second['items'], 'id')); + self::assertNull($second['next_cursor']); + $json = (string) json_encode([$first, $second]); + self::assertStringNotContainsString('[Fixture] 1055', $json); + self::assertStringNotContainsString('[Fixture] 1057', $json); + self::assertSame(['items', 'next_cursor'], array_keys($first)); + } + + /** + * Audit L3: the cursor counts only the entries this user was shown – how many hidden entries + * match a search (and sort before) stays unknown. + */ + public function testTheCursorCountsNoHiddenEntries(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1054, 1055]); + $ids = [1054, 1055, 1056, 1057, 1058]; + + $first = $this->ability([], $ids)->execute(['limit' => 1]); + $second = $this->ability([], $ids)->execute(['limit' => 1, 'cursor' => $first['next_cursor']]); + + self::assertSame([1056], array_column($first['items'], 'id')); + self::assertSame(Cursor::encode(1), $first['next_cursor']); + self::assertSame([1057], array_column($second['items'], 'id')); + self::assertSame(Cursor::encode(2), $second['next_cursor']); + } + + /** Audit L3 (author oracle): without edit_others_posts the query itself holds only the own entries. */ + public function testAnAuthorQueriesOnlyTheirOwnEntries(): void + { + LibraryFixtures::given([]); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(12); + $store = LibraryFixtures::store(); + $store->expects('find')->with(['search' => 'Geheim', 'author' => 12], 20, 0)->andReturn([]); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + LibraryFixtures::usages([]) + ); + + self::assertSame(['items' => [], 'next_cursor' => null], $ability->execute(['search' => 'Geheim'])); + } + + /** Review M-12: when the scan cap stops a page, the answer says so. */ + public function testSaysWhenTheScanCapIsReached(): void + { + LibraryFixtures::given([]); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) >= 5000) + ); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_get_object_terms')->justReturn([]); + $hidden = array_map( + static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'et_pb_layout', 'post_title' => 'x']), + range(5000, 5000 + LibraryListAbility::MAX_SCAN + 10) + ); + $posts = array_merge([LibraryFixtures::item(1055)], $hidden, [LibraryFixtures::item(1056)]); + $store = LibraryFixtures::store(); + $store->allows('find')->andReturnUsing( + static fn (array $filters, int $limit, int $offset): array => array_slice($posts, $offset, $limit + 1) + ); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + LibraryFixtures::usages([]) + ); + + $result = $ability->execute(['limit' => 5]); + + self::assertSame([1055], array_column($result['items'], 'id')); + self::assertSame(Cursor::encode(1), $result['next_cursor']); + self::assertStringContainsString('500', $result['notes'][0]); + } + + public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1058, 1059]); + + $result = $this->ability()->execute(['limit' => 1]); + + self::assertSame([1055], array_column($result['items'], 'id')); + self::assertNull($result['next_cursor']); + } + + /** Ruling P12: without the library right, an id reads exactly like a missing ID; the list says forbidden. */ + public function testWithoutTheLibraryRightAnIdIsNotFoundLikeAMissingOne(): void + { + $missing = PostGuard::notFound(1058); + $cases = [ + 'no divi_library' => [[], ['divi_library']], + 'no edit_posts' => [['edit_posts'], []], + ]; + foreach ($cases as $case => [$deniedCaps, $deniedRoles]) { + LibraryFixtures::given([LibraryFixtures::item(1058), LibraryFixtures::page('')], $deniedCaps); + $ability = $this->ability($deniedRoles); + foreach ([$ability->permission(['id' => 1058]), $ability->execute(['id' => 1058])] as $result) { + self::assertInstanceOf(WP_Error::class, $result, $case); + self::assertSame($missing->get_error_code(), $result->get_error_code(), $case); + self::assertSame($missing->get_error_message(), $result->get_error_message(), $case); + self::assertSame($missing->get_error_data(), $result->get_error_data(), $case); + } + self::assertToolError($ability->execute(['id' => 99999]), 'not_found'); + self::assertToolError($ability->execute(['id' => LibraryFixtures::PAGE]), 'not_found'); + self::assertToolError($ability->execute([]), 'forbidden'); + self::assertToolError($ability->permission([]), 'forbidden'); + } + } + + public function testAnInvalidIdIsAValidationError(): void + { + LibraryFixtures::given([]); + + self::assertToolError($this->ability()->execute(['id' => 'abc']), 'validation_failed'); + } + + public function testDetailWithoutEditPostIsForbiddenWithoutTheTitle(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058, ['post_title' => 'Geheim'])]); + self::withoutEditPostOn([1058]); + + $result = $this->ability()->execute(['id' => 1058]); + + self::assertToolError($result, 'forbidden'); + self::assertStringNotContainsString('Geheim', (string) json_encode([$result->get_error_message(), $result->get_error_data()])); + self::assertToolError($this->ability()->permission(['id' => 1058]), 'forbidden'); + } + + /** Audit L2: a normal entry that a page shows as an instance names that page too. */ + public function testDetailOfAReferencedNormalEntryNamesItsPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1056)]); + + $result = $this->ability([], [1056])->execute(['id' => 1056]); + + self::assertSame('normal', $result['items'][0]['scope']); + self::assertSame(1, $result['affected_count']); + self::assertSame(1053, $result['affected'][0]['post_id']); + } + + public function testDetailOfANormalEntryHasNoAffectedPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + + $result = $this->ability()->execute(['id' => 1055]); + + self::assertSame('normal', $result['items'][0]['scope']); + self::assertArrayNotHasKey('affected', $result); + self::assertTrue($this->ability()->permission(['id' => 1055])); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php new file mode 100644 index 0000000..1f35080 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php @@ -0,0 +1,240 @@ + */ + private array $specs = []; + + /** @var list */ + private array $contents = []; + + /** @var list Contents DiviWriter saved (the source page must never be written). */ + private array $saved = []; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + LibraryFixtures::given([$this->page]); + Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture' + ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy]) + : false); + $this->log = new FakeChangeLog(4711); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility + { + $store = LibraryFixtures::store(); + $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { + $this->specs[] = $spec; + + return 2001; + }); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array { + if ($layoutFailure !== null) { + throw $layoutFailure; + } + $this->contents[] = [$id, $content, $scope]; + LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]); + + return []; + }); + $access = LibraryFixtures::access($deniedRoles, $writable, $store); + $failures = []; + + return new LibrarySaveAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)), + $saver, + $this->log, + DiviWriterFixture::modules() + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge( + ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], + $extra + ); + } + + public function testSavesASectionLikeTheBuilder(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Fixture']])); + + self::assertTrue($result['ok']); + self::assertSame(2001, $result['library_id']); + self::assertSame('section', $this->specs[0]->layoutType); + self::assertSame([26], $this->specs[0]->terms()['layout_category']); + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), + $this->contents[0][1] + ); + self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]); + self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']); + self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); + self::assertSame('2001', $this->log->records[0]['object_id']); + } + + public function testSavesAGlobalModule(): void + { + $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global'])); + + self::assertTrue($result['ok']); + self::assertTrue($this->specs[0]->global); + self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']); + self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']); + } + + public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testStaleSourceTreeRevCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch'); + self::assertSame([], $this->specs); + } + + public function testNeedsPublishPostsAndTheDiviRole(): void + { + self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden'); + LibraryFixtures::given([$this->page], ['publish_posts']); + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertSame([], $this->specs); + } + + public function testEditorCannotSaveACodeModule(): void + { + $this->page = LibraryFixtures::page( + '' + . '' + . '' + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result))); + self::assertSame([], $this->specs); + } + + public function testUnknownCategoryCreatesNothing(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->specs); + } + + /** Ruling P6: the layout write failing rolls the new entry back through NewDraft (write_failed). */ + public function testFailedLayoutWriteDeletesTheEntry(): void + { + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(true); + + try { + $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertToolError($e->toError(), 'write_failed'); + } + self::assertSame([], $this->log->records); + } + + /** Ruling P6: when the rollback fails too, the answer is partial_write and the change log says so. */ + public function testFailedRollbackIsAPartialWrite(): void + { + Functions\when('wp_delete_post')->justReturn(false); + $this->log = FakeChangeLog::any(4711); + + try { + $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + self::assertToolError($e->toError(), 'partial_write'); + } + self::assertSame('et_pb_layout', $this->log->partials[0]['object_type']); + self::assertSame('2001', $this->log->partials[0]['object_id']); + } + + /** Ruling P7: scope "global" also needs Divi's edit_global_library. */ + public function testGlobalScopeNeedsEditGlobalLibrary(): void + { + $input = $this->input(['id' => 's0.r0.c0.m0', 'scope' => 'global']); + + self::assertToolError($this->ability(['edit_global_library'])->permission($input), 'forbidden'); + self::assertToolError($this->ability(['edit_global_library'])->execute($input), 'forbidden'); + self::assertTrue($this->ability(['edit_global_library'])->permission($this->input())); + self::assertSame([], $this->specs); + } + + public function testTheSourcePageIsNeverWritten(): void + { + $this->ability()->execute($this->input(['scope' => 'normal'])); + + self::assertSame([], $this->saved); + self::assertCount(1, $this->contents); + self::assertSame(2001, $this->contents[0][0]); + } + + /** Line breaks between blocks are unified like LibraryContent::load() (fixture finding 9). */ + public function testLineBreaksBetweenBlocksAreUnified(): void + { + $this->page = LibraryFixtures::page( + "\r\n\r\n" + . '' + . "\r\n\r\n" + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertTrue($result['ok']); + self::assertStringNotContainsString("\r", $this->contents[0][1]); + self::assertStringContainsString("\n", $this->contents[0][1]); + } + + public function testUnknownScopeCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed'); + self::assertSame([], $this->specs); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php new file mode 100644 index 0000000..dbac8cd --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -0,0 +1,123 @@ + Content => whether it shows entry 123. */ + public static function contents(): array + { + $escapedKey = '"' . self::u('0067') . 'lobalModule"'; + + return [ + 'divi 5 canonical' => ['', true], + 'divi 5 leading zeros' => ['', true], + 'divi 5 escaped key' => ['', true], + 'divi 5 escaped value' => [ + '', + true, + ], + 'divi 5 escaped key with leading zeros' => [ + '', + true, + ], + 'divi 5 another entry' => ['', false], + 'divi 5 escaped key, another entry' => ['', false], + 'divi 5 id in local attributes' => [ + '', + true, + ], + 'divi 4 double quotes' => ['[et_pb_section global_module="123"][/et_pb_section]', true], + 'divi 4 single quotes and zeros' => ["[et_pb_text global_module='0123']x[/et_pb_text]", true], + 'divi 4 unquoted' => ['[et_pb_row global_module=123 admin_label="x"][/et_pb_row]', true], + 'divi 4 typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]', true], + 'divi 4 another entry' => ['[et_pb_section global_module="1230"][/et_pb_section]', false], + 'undecodable escaped opener' => [ + '', + true, + ], + 'undecodable escaped opener naming nothing readable' => [ + '', + true, + ], + 'escaped markup only' => [ + '', + false, + ], + 'plain page' => ['', false], + // Ruling P24 G1: a Divi 4 shortcode inside a Divi 5 attribute value (quotes serialized as escapes). + 'shortcode in a divi 5 text value' => [self::inText('[et_pb_section global_module=' . self::u('0022') . '123' + . self::u('0022') . '][/et_pb_section]'), true], + 'shortcode in a divi 5 text value, another entry' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . '124' . self::u('0022') . '][/et_pb_section]'), false], + 'shortcode in a divi 5 text value, escaped digits' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . self::u('0031') . self::u('0032') . '3' . self::u('0022') . '][/et_pb_section]'), true], + // G2: parsed like shortcode_parse_atts, cast with (int) like Divi. + 'divi 4 leading space' => ['[et_pb_section global_module=" 123"][/et_pb_section]', true], + 'divi 4 exponent' => ['[et_pb_section global_module="1.23e2"][/et_pb_section]', true], + 'divi 4 upper-case attribute' => ['[et_pb_section GLOBAL_MODULE="123"][/et_pb_section]', true], + 'divi 4 zero renders nothing' => ['[et_pb_section global_module="0"][/et_pb_section]', false], + 'divi 4 empty renders nothing' => ['[et_pb_section global_module=""][/et_pb_section]', false], + 'global_module as plain text' => ['

global_module="123" steht hier nur als Text

', false], + ]; + } + + /** A Divi 5 text module whose content value holds $text, JSON-encoded like WordPress does. */ + private static function inText(string $text): string + { + return ''; + } + + /** + * Ruling P24 G3: a global_module Divi casts to 0 renders the newest library or TB layout + * (et_pb_load_global_module, WP_Query p=0) – a reference to every entry. + * + * @return array + */ + public static function wildcards(): array + { + return [ + 'entity before the id' => ['[et_pb_section global_module=""123"][/et_pb_section]'], + 'letters' => ['[et_pb_row global_module="abc"][/et_pb_row]'], + 'typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]'], + 'double zero' => ['[et_pb_row global_module="00"][/et_pb_row]'], + 'inside a divi 5 value' => [self::inText('[et_pb_text global_module=' . self::u('0022') . 'x' + . self::u('0022') . ']a[/et_pb_text]')], + ]; + } + + /** @dataProvider wildcards */ + public function testAValueThatCastsToZeroShowsEveryEntry(string $content): void + { + self::assertTrue(UsageMatcher::shows($content, 123)); + self::assertTrue(UsageMatcher::shows($content, 98765)); + } + + /** @dataProvider contents */ + public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void + { + self::assertSame($shows, UsageMatcher::shows($content, 123)); + } + + public function testTheEscapeBytesAreReal(): void + { + self::assertSame(6, strlen(self::u('0067'))); + self::assertSame(92, ord(self::u('0067')[0])); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index a565513..d54b5b8 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; +use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; @@ -16,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -37,7 +39,14 @@ final class DiviCompositionTest extends TestCase return DiviFixtures::dynamicContentRegistry(); }); - return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry); + return new DiviComposition( + $modules, + $design, + ComposeFixture::sanitizer(), + ComposeFixture::media(), + $registry, + ComposeFixture::links() + ); } private static function siteTitle(): string @@ -74,6 +83,24 @@ final class DiviCompositionTest extends TestCase self::assertSame($composition->references(), $composition->references()); } + /** E13a: the composition hands its link targets to Placeholders; the result still passes ValueCleaner. */ + public function testInternalLinksUseTheLinkTargets(): void + { + $composer = $this->composition()->forPost(7)->composer(); + + $column = Block::create('divi/column'); + + $page = $composer->compose([['type' => 'button', 'text' => 'Kontakt', 'url' => '{{link:839}}']], $column, 'nodes'); + self::assertSame([], $page['errors']); + self::assertStringContainsString( + 'post_link_url_page', + json_encode($page['blocks'][0]->attrs(), JSON_THROW_ON_ERROR) + ); + + $missing = $composer->compose([['type' => 'button', 'url' => '{{link:999}}']], $column, 'nodes'); + self::assertStringContainsString('Eintrag 999', $missing['errors']['nodes[0].url']); + } + public function testWithoutAPostTheDefaultMapApplies(): void { $composition = $this->composition(); @@ -112,4 +139,21 @@ final class DiviCompositionTest extends TestCase self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); self::assertInstanceOf(TreeEditor::class, $composition->editor()); } + + public function testStoredGuardSharesTheCleanerAndReferences(): void + { + $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); + $code = Block::create('divi/code', ['builderVersion' => '5.2.1']); + + self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); + self::assertSame( + [], + $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) + ); + self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used'); + self::assertSame( + [], + DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code) + ); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php new file mode 100644 index 0000000..8eabe10 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php @@ -0,0 +1,179 @@ +justReturn( + (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] + ); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('fields')->with('page')->andReturn([ + 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), + 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), + 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), + 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), + '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), + 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), + 'gefunden' => new FieldDefinition('gefunden', 'gefunden', 'unknown', 'sample', FieldDefinition::UNKNOWN), + 'aktiv' => new FieldDefinition('aktiv', 'Aktiv', 'boolean', 'pods', FieldDefinition::WRITABLE), + 'kennwort' => new FieldDefinition('kennwort', 'Kennwort', 'password', 'pods', FieldDefinition::WRITABLE), + ]); + + return $schema; + } + + /** + * @param list $deniedRoles + * @param array|null $registry + */ + private function ability( + array $deniedRoles = [], + ?string $storage = 'meta', + ?array $registry = null + ): DiviDynamicSourcesAbility { + $registry ??= DiviFixtures::dynamicContentRegistry(); + + return new DiviDynamicSourcesAbility( + $this->schema(), + new DynamicContentMap(static fn (int $id): array => $registry), + LibraryFixtures::roles($deniedRoles), + static fn (string $postType): int => 831, + static fn (string $postType): ?string => $storage + ); + } + + public function testListsDiviSourcesWithTheirPlaceholders(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + $sources = array_column($result['sources'], null, 'name'); + self::assertSame(831, $result['sample_post_id']); + self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['site_title']['placeholder']); + self::assertArrayNotHasKey('loop_post_title', $sources); + self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); + self::assertFalse($this->ability()->isDirect()); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertSame('lit/divi-dynamic-sources', $this->ability()->name()); + } + + /** Review M-9: {{link:}} refuses attachments (status inherit), so their link sources get no hint. */ + public function testNoLinkHintForAttachmentsOrInternalTypes(): void + { + $registry = DiviFixtures::dynamicContentRegistry(); + foreach (['post_link_url_attachment', 'post_link_url__et_pb_speculation'] as $name) { + $registry[$name] = $registry['post_link_url_page']; + $registry[$name]['id'] = $name; + } + + $sources = array_column($this->ability([], 'meta', $registry)->execute(['post_type' => 'page'])['sources'], null, 'name'); + + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['post_link_url_attachment']['placeholder']); + self::assertNull($sources['post_link_url__et_pb_speculation']['placeholder']); + } + + public function testOffersTextLikeFieldsAndNamesTheOthers(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); + self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); + $omitted = array_column($result['omitted'], 'reason', 'key'); + self::assertSame(['foto', 'projekte', '_intern', 'gefunden', 'aktiv', 'kennwort'], array_keys($omitted)); + self::assertStringContainsString('nur IDs', $omitted['foto']); + self::assertStringContainsString('nur IDs', $omitted['projekte']); + self::assertStringContainsString('„_“', $omitted['_intern']); + // Ruling P9: unknown field types are not offered. + self::assertStringContainsString('unbekannt', $omitted['gefunden']); + self::assertStringContainsString('nicht textartig', $omitted['aktiv']); + self::assertStringContainsString('nicht textartig', $omitted['kennwort']); + } + + public function testProtectedAndHiddenFieldsLeakNothing(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + $json = (string) json_encode($result); + + // Hidden fields are neither offered nor named (security audit M4). + self::assertStringNotContainsString('geheim', $json); + self::assertStringNotContainsString('Geheim', $json); + // The protected key is named (so the client knows why) but never offered with a placeholder. + self::assertStringNotContainsString('{{field:_intern}}', $json); + self::assertSame(['key', 'label', 'reason'], array_keys($result['omitted'][2])); + } + + public function testSourcesNeverNameProtectedMetaOptions(): void + { + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + ]; + + $result = $this->ability([], 'meta', $registry)->execute(['post_type' => 'page']); + + self::assertStringNotContainsString('_edit_lock', (string) json_encode($result)); + } + + public function testPodsTableStorageIsNotReadableByDivi(): void + { + $result = $this->ability([], 'table')->execute(['post_type' => 'page']); + + self::assertSame(['telefon'], array_column($result['fields'], 'key')); + self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); + } + + public function testFieldListNeedsTheDiviRole(): void + { + $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); + + self::assertSame([], $result['fields']); + self::assertSame([], $result['omitted']); + self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); + } + + public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void + { + $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); + + self::assertContains('post_title', array_column($result['sources'], 'name')); + self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); + } + + public function testUnknownPostTypeIsNotFound(): void + { + Functions\when('get_post_type_object')->justReturn(null); + + self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); + self::assertToolError($this->ability()->execute(['post_type' => 'nix']), 'not_found'); + } + + public function testNeedsTheTypesEditCapability(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + + self::assertToolError($this->ability()->permission(['post_type' => 'page']), 'forbidden'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php new file mode 100644 index 0000000..ab2696e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php @@ -0,0 +1,69 @@ + */ + public static function pages(): array + { + $pages = []; + foreach (['', 'stage3/'] as $dir) { + foreach (glob(dirname(__DIR__, 4) . '/Fixtures/divi/' . $dir . '*.html') ?: [] as $file) { + $name = $dir . basename($file); + if ($name !== 'divi4-shortcodes.html') { + $pages[$name] = [$name]; + } + } + } + + return $pages; + } + + /** @dataProvider pages */ + public function testAnEditorInsertKeepsEveryOtherByte(string $fixture): void + { + $original = DiviFixtures::markup($fixture); + self::assertNotSame('', $original, $fixture); + DiviFixtures::givenPost($original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $saved = []; + + $result = (new DiviInsertAbility(DiviWriterFixture::writer($saved, unfiltered: false)))->execute([ + 'post_id' => 7, + 'tree_rev' => Rev::of($original), + 'nodes' => [['type' => 'section']], + ]); + + self::assertTrue($result['ok'] ?? false, $fixture . ': ' . json_encode($result)); + self::assertCount(1, $saved); + $node = NodeIndex::build((new Parser())->parse($saved[0]))->find($result['changed'][0]['id']); + self::assertNotNull($node, $fixture); + $inserted = Serializer::block($node); + $at = strpos($saved[0], $inserted); + self::assertNotFalse($at, $fixture); + self::assertSame( + $original, + substr($saved[0], 0, (int) $at) . substr($saved[0], (int) $at + strlen($inserted)), + $fixture . ': only the new section was added' + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php new file mode 100644 index 0000000..7ad4241 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -0,0 +1,305 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + $this->source = LibraryFixtures::page( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + ['ID' => 8, 'post_title' => 'Quelle'] + ); + LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]); + } + + private function ability( + bool $diviWritable = true, + bool $unfiltered = false, + ?LibraryAccess $access = null, + bool $libraryInsert = true + ): DiviNodeCopyAbility { + $access ??= LibraryFixtures::access(); + $loader = LibraryFixtures::loader($access); + $failures = []; + $globals = new GlobalEdits($access, LibraryFixtures::usages([])); + + return new DiviNodeCopyAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), + $loader, + static fn (): bool => $diviWritable, + static fn (): bool => $libraryInsert + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge([ + 'post_id' => 7, + 'tree_rev' => Rev::of((string) $this->target->post_content), + 'source_post_id' => 8, + 'source_tree_rev' => Rev::of((string) $this->source->post_content), + 'source_id' => 's1', + 'after' => 's0', + ], $extra); + } + + private function sourceBytes(string $id): string + { + return Serializer::block( + NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find($id) + ); + } + + public function testCopiesANodeByteForByteBetweenPages(): void + { + $bytes = $this->sourceBytes('s1'); + + $result = $this->ability()->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertCount(1, $this->saved, 'only the target is written'); + self::assertStringContainsString($bytes, $this->saved[0]); + self::assertSame('s1', $result['changed'][0]['id']); + self::assertFalse($this->ability()->isDirect()); + } + + public function testLeavesTheRestOfTheTargetByteIdentical(): void + { + $original = (string) $this->target->post_content; + $bytes = $this->sourceBytes('s1'); + + $this->ability()->execute($this->input()); + + self::assertSame($original, str_replace($bytes, '', $this->saved[0])); + self::assertSame( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + (string) $this->source->post_content, + 'the source is only read' + ); + } + + public function testGlobalInstancesStayLinked(): void + { + $result = $this->ability()->execute($this->input(['source_id' => 'g0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability()->execute($this->input(['source_id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAnAdminMayCopyBuilderOnlyValues(): void + { + $this->source = LibraryFixtures::page( + '' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability(unfiltered: true)->execute($this->input(['source_id' => 's0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } + + public function testTheSourceNeedsItsOwnRights(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8) + ); + + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability()->execute($this->input()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testFollowsLitDiviInsertExposure(): void + { + $ability = $this->ability(diviWritable: false); + + self::assertToolError($ability->permission($this->input()), 'unsupported'); + self::assertToolError($ability->execute($this->input()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testCopiesIntoALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1055, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.m0', + 'after' => null, + 'parent' => 's0.r0.c0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]); + } + + public function testCopiesFromALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + self::assertStringContainsString(chr(13), (string) $item->post_content, 'the entry uses CRLF'); + self::assertStringNotContainsString(chr(13), $this->saved[0], 'line breaks unified like LibraryContent::load'); + } + + /** @return array */ + private function librarySource(): array + { + return $this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), + 'source_id' => 's0', + ]); + } + + public function testALibrarySourceNeedsAddLibrary(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library'])); + + self::assertToolError($ability->permission($this->librarySource()), 'forbidden'); + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceNeedsAWritableLibraryGroup(): void + { + $ability = $this->ability(access: LibraryFixtures::access(writable: false)); + + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceFollowsLibraryInsertExposure(): void + { + $ability = $this->ability(libraryInsert: false); + + self::assertToolError($ability->permission($this->librarySource()), 'unsupported'); + self::assertToolError($ability->execute($this->librarySource()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testPageSourcesNeedNeitherAddLibraryNorLibraryInsert(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library']), libraryInsert: false); + + self::assertTrue($ability->execute($this->input())['ok']); + } + + public function testAHiddenLibraryIsNoSource(): void + { + $item = LibraryFixtures::item(1055); + $access = new LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + static fn (): bool => true, + static fn (): bool => false + ); + + $result = $this->ability(access: $access)->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertToolError($result, 'not_found'); + self::assertSame([], $this->saved); + } + + public function testNeverNestsAnInstanceInAGlobalEntry(): void + { + $item = LibraryFixtures::item(1058); + LibraryFixtures::given([$this->target, $this->source, $item]); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1058, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.g0', + 'after' => null, + 'parent' => 's0.r0.c0', + 'confirm' => true, + ])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('keine globalen Module', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testRefusesAPlaceThatBreaksTheStructure(): void + { + $result = $this->ability()->execute($this->input(['after' => null, 'parent' => 's0.r0.c0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testRefusesAMissingSourceId(): void + { + self::assertToolError($this->ability()->execute($this->input(['source_id' => null])), 'not_found'); + self::assertSame([], $this->saved); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 5f2b13e..115a855 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -7,6 +7,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Tests\Support\LibraryFixtures; +use Mockery; use LIT\MCP\Tests\Unit\TestCase; use WP_Error; @@ -96,4 +101,62 @@ final class DiviPageLoaderTest extends TestCase DiviFixtures::givenPost("\n

Gutenberg

\n"); self::assertToolError($loader->loadWritable(7), 'unsupported'); } + + public function testLoadsALibraryItemForReading(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $page = LibraryFixtures::loader()->load(1058, false); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + self::assertSame('divi/section', $page->index->find('s0')?->name()); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl()); + } + + public function testLibraryItemsNeedTheLibraryAccess(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); + self::assertToolError( + (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), + 'not_found' + ); + } + + public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access()); + + $page = $loader->loadWritable(1057); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + } + + public function testWritingALibraryItemNeedsTheLibraryAccessAndItsEditRights(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + self::assertToolError((new DiviPageLoader($schema))->loadWritable(1057), 'not_found'); + self::assertToolError( + (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access([], false)))->loadWritable(1057), + 'forbidden' + ); + } + + /** Ruling P9: the group "Divi-Bibliothek" disabled hides library entries from the Divi read tools. */ + public function testAHiddenLibraryLeavesLibraryItemsNotFound(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); + + self::assertToolError(LibraryFixtures::loader($hidden)->load(1058, false), 'not_found'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 11e7f0d..e934793 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use stdClass; @@ -123,4 +124,47 @@ final class DiviReadAbilitiesTest extends TestCase self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed'); self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found'); } + + public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + + $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); + + self::assertSame('1058', $global['global']['id']); + self::assertSame('divi/section', $global['global']['block_name']); + self::assertStringContainsString('post_id 1058', $global['global']['hint']); + self::assertFalse($global['editable']); + self::assertNull($plain['global']); + } + + public function testOutlineOfALibraryItem(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1059]); + + self::assertSame(['m0'], array_column($result['nodes'], 'id')); + self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); + } + + public function testNodeGetOfALibraryItemAndNotFoundWithoutTheLibraryRight(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]); + $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1057, 'id' => 'm0']); + self::assertSame('m0', $result['id']); + + $denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); + $refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']); + self::assertToolError($refusal, 'not_found'); + self::assertStringNotContainsString('Geheim', $refusal->get_error_message()); + $outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); + self::assertToolError($outline->execute(['post_id' => 1057]), 'not_found'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php index d6840c2..f06ae84 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Tests\Unit\TestCase; use RuntimeException; use WP_Error; @@ -267,4 +268,36 @@ final class DiviSaverTest extends TestCase $this->expectException(RuntimeException::class); (new DiviSaver())->saveNew(42, 'NEU'); } + + public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void + { + \ET_Core_PageResource::$removed = []; + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + $cleared = []; + Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void { + $cleared[] = $what; + }); + + (new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame([], $cleared); + + \ET_Core_PageResource::$removed = []; + (new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem); + self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame(['all'], $cleared); + } + + public function testSaveNewOfALibraryItemSetsNoFlags(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU'])); + + self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem)); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 3136006..0c26f7a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -143,6 +143,73 @@ final class DynamicContentMapTest extends TestCase self::assertNull((new DynamicContentMap())->forPost(7)); } + public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']); + self::assertSame('post_meta_key', $options['post_meta_key']['label']); + self::assertSame('Standard', $options['post_meta_key']['group']); + self::assertArrayNotHasKey('loop_post_title', $options); + self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831)); + } + + public function testOptionsUseTheSameNamesAndTypesAsTheAllowMap(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(array_keys((array) $map->forPost(831)), array_keys((array) $options)); + $settings = array_map(static fn (array $option): array => $option['settings'], (array) $options); + self::assertSame($map->forPost(831), $settings); + // Placeholder options the registry lacks: name as label, no group. + self::assertSame(['label' => 'post_tags', 'group' => ''], array_slice($options['post_tags'], 0, 2)); + } + + public function testOptionsNeverNameProtectedMetaKeys(): void + { + // A third-party filter may register per-key options (D4 style custom_meta_). + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + 'custom_meta_custom_meta__secret' => ['label' => 'x', 'group' => 'Custom', 'fields' => []], + 'custom_meta_telefon' => ['label' => 'Telefon', 'group' => 'Custom', 'fields' => []], + ]; + $map = new DynamicContentMap(static fn (int $id): array => $registry); + + $options = (array) $map->options(831); + + self::assertArrayNotHasKey('custom_meta__edit_lock', $options); + self::assertArrayNotHasKey('custom_meta_custom_meta__secret', $options); + self::assertSame('Telefon', $options['custom_meta_telefon']['label']); + // The allow map itself is unchanged: VariableRef refuses those names by its own rules (S12/S30). + self::assertArrayHasKey('custom_meta__edit_lock', (array) $map->forPost(831)); + } + + public function testTheAllowMapIsUnchangedByTheRawLookupRefactor(): void + { + // Snapshot of forPost() for the live-like registry, taken before forPost() moved onto raw() + // (Task 15): the security allow map must stay exactly the same (rulings S22/S29/S30/S37). + $before = '{"post_title":{"before":"richtext","after":"richtext"},"post_excerpt":{"before":"richtext",' + . '"after":"richtext","words":"text","read_more_label":"text"},"post_date":{"before":"richtext",' + . '"after":"richtext","date_format":"select","custom_date_format":"text"},"post_author":{"before":' + . '"richtext","after":"richtext","name_format":"select","link":"yes_no_button"},"site_title":{"before":' + . '"richtext","after":"richtext"},"post_link_url":[],"post_link_url_page":{"post_id":"select_post"},' + . '"post_featured_image":[],"post_meta_key":{"before":"richtext","after":"richtext","select_meta_key":' + . '"select","meta_key":"text","enable_html":"yes_no_button"},"broken_fields":{"a":"","b":""},' + . '"no_fields":[],"post_modified_date":{"before":"richtext","after":"richtext","date_format":"select",' + . '"custom_date_format":"text"},"post_categories":{"before":"richtext","after":"richtext"},' + . '"post_tags":{"before":"richtext","after":"richtext"}}'; + + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + $after = $map->forPost(831); + + self::assertSame(json_decode($before, true), $after); + self::assertSame($before, json_encode($after)); + } + private static function assertRejected(callable $call, string $fragment): void { try { diff --git a/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php new file mode 100644 index 0000000..d62ef23 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php @@ -0,0 +1,37 @@ + new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']), + 841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']), + 842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']), + 843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']), + 844 => new WP_Post(['ID' => 844, 'post_type' => 'page', 'post_status' => 'auto-draft']), + ]; + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842 + ); + + $links = new WpLinkTargets(); + + self::assertSame('page', $links->postTypeOf(839)); + self::assertNull($links->postTypeOf(841), 'trashed'); + self::assertNull($links->postTypeOf(842), 'not readable'); + self::assertNull($links->postTypeOf(843), 'revision'); + self::assertNull($links->postTypeOf(844), 'auto-draft'); + self::assertNull($links->postTypeOf(900), 'missing'); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php index 3c749a5..f11d05a 100644 --- a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -62,4 +62,26 @@ final class NewDraftTest extends TestCase self::assertSame(NewDraft::REASON_EXTRAS, $e->reason); } } + + /** lit/library-save reuses the transaction without a PostWriter and names its entry (ruling P6). */ + public function testWithoutAWriterOnlyTheEntryIsDeletedAndPartialsNameTheEntry(): void + { + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + $draft = new NewDraft(null, $log, 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag'); + + try { + $draft->discard(2001, new RuntimeException('layout kaputt'), 'Grund.'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + } + $log->assertPartials([ + 'ability' => 'lit/library-save', + 'object_type' => 'et_pb_layout', + 'object_id' => '2001', + 'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen', + ]); + } } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php index 62d4d8b..e154723 100644 --- a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -8,6 +8,8 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; use LIT\MCP\Integrations\WordPress\Render\RenderSummary; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\SchemaFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -138,4 +140,33 @@ final class PageRenderSummaryAbilityTest extends TestCase self::assertTrue($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); } + + public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer); + + $notes = $ability->execute(['post_id' => 7])['notes']; + + $globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul'))); + self::assertCount(2, $globals); + self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]); + self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]); + } + + public function testLibraryEntriesAreNoManagedContent(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout'])); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + $ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class)); + + self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found'); + } } diff --git a/tests/Unit/PluginHeaderTest.php b/tests/Unit/PluginHeaderTest.php new file mode 100644 index 0000000..2b18b04 --- /dev/null +++ b/tests/Unit/PluginHeaderTest.php @@ -0,0 +1,20 @@ +