merge: Etappe 3a (v0.4.0) – Divi-Bibliothek, globale Module, Link-Platzhalter, dynamische Quellen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
"psr-4": { "LIT\\MCP\\Tests\\": "tests/" }
|
"psr-4": { "LIT\\MCP\\Tests\\": "tests/" }
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "@php vendor/bin/phpunit",
|
"test": "@php -d memory_limit=512M vendor/bin/phpunit",
|
||||||
"lint": "@php vendor/bin/phpcs"
|
"lint": "@php vendor/bin/phpcs"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -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
|
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,
|
(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
|
geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute,
|
||||||
(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im
|
Interaktionen und die Lottie-Quelle (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`,
|
||||||
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
|
`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).
|
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
|
## Pfade finden
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
- **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und
|
- **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.
|
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:
|
Regeln:
|
||||||
|
|
||||||
1. Vor dem Gestalten einer Seite das Designsystem lesen.
|
1. Vor dem Gestalten einer Seite das Designsystem lesen.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -12,6 +12,7 @@ sich auch der Button.
|
|||||||
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |
|
| `{{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 |
|
| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags |
|
||||||
| `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
|
| `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
|
||||||
|
| `{{link:<ID>}}` | 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": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
|
| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
|
||||||
|
|
||||||
Beispiele:
|
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 `_`
|
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.
|
beginnt (geschützte Meta-Daten), sind nicht erlaubt.
|
||||||
|
|
||||||
In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`;
|
In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:<ID>}}` verwenden statt fester
|
||||||
sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
|
Adressen wie `/?page_id=…`. Der Eintrag muss existieren und für das Konto lesbar sein; Einträge im Papierkorb,
|
||||||
Link-Variable).
|
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=<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
|
## `$variable`-Werte
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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:`.
|
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`,
|
`$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`,
|
||||||
`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der
|
`…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
|
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`
|
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).
|
weiterarbeiten).
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert
|
|||||||
## Was nicht bearbeitet wird
|
## Was nicht bearbeitet wird
|
||||||
|
|
||||||
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
|
- 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).
|
duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global <ID>]`: ihren Inhalt im
|
||||||
`lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`.
|
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
|
- 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.
|
ö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
|
- 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
|
`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).
|
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.
|
||||||
|
|||||||
+10
-6
@@ -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-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-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-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
|
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“
|
(„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
|
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
|
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
|
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
||||||
Hinweisen immer so geschrieben):
|
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/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`),
|
(`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`,
|
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`,
|
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
|
## Rückmeldungen
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder)
|
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`).
|
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
|
## Text auf vielen Seiten ersetzen
|
||||||
|
|
||||||
`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach
|
`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
|
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,
|
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
|
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
|
## Menüs pflegen
|
||||||
|
|
||||||
|
|||||||
@@ -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":"<ID>","blockName":"divi/<typ>","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_<id>` 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:<pt>:all` (`:703-710`), `archive:post_type:<pt>` (nur `post` oder `has_archive`, `:724-734`), `singular:post_type:<pt>:id:<id>` (`:805-813`), `singular:post_type:<pt>:children:id:<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_<pt>` 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_<key>`: 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_<post_type>` 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=<global_id> bearbeiten“ (S19-Teil 2). Schreiben in den Library-Post wirkt auf alle Instanzen (F7); die Antwort nennt die betroffenen Seiten (Verwendungssuche über `"globalModule":"<id>"` 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 `<!-- wp:divi/global-layout {"globalModule":"<id>","blockName":"<Wurzeltyp>"} --><!-- /wp:divi/global-layout -->` 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:<name>}}`; 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:<pt>:…`); 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_id>}}` → `post_link_url_<post_type>` 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 <id> – 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:… -->`, 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_<pt>` + `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:<ID>}}` 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=<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).
|
||||||
@@ -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:<ID von Seite A>}}` 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=<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.
|
||||||
@@ -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 <p> → <p> (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
|
||||||
@@ -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 `<!-- wp:divi/global-layout {"globalModule":"1910","blockName":"divi/text"} /-->`.
|
||||||
|
- `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 = <library entry>`.
|
||||||
|
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="<id>"`. 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).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,12 @@ Die Pflege von Kunden-Websites (WordPress + Divi 5 + Pods) kostet viel Handarbei
|
|||||||
|
|
||||||
### 1.2 Ziel
|
### 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.3 Erfolgskriterien
|
||||||
|
|
||||||
1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen.
|
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).
|
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.
|
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).
|
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:
|
- **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`.
|
`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`).
|
- **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.
|
- In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind.
|
||||||
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
|
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
|
||||||
@@ -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).
|
- **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).
|
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
|
||||||
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
|
- 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
|
### 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.
|
- `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).
|
- 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.
|
- 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
|
### 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`) |
|
| `{{font:heading}}`, `{{font:body}}` | Theme-Schriften (`--et_global_heading_font` / `--et_global_body_font`) |
|
||||||
| `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) |
|
| `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) |
|
||||||
| `{{field:<meta_key>}}` | Dynamic Content `custom_meta_<meta_key>` (Pods/Meta) |
|
| `{{field:<meta_key>}}` | Dynamic Content `custom_meta_<meta_key>` (Pods/Meta) |
|
||||||
|
| `{{link:<ID>}}` | interner Link: Dynamic Content `post_link_url_<Typ>` 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": "<Name>"` | `modulePreset: ["<id>"]` |
|
| `"preset": "<Name>"` | `modulePreset: ["<id>"]` |
|
||||||
|
|
||||||
Unbekannte Namen → Fehler mit Liste der vorhandenen.
|
Unbekannte Namen → Fehler mit Liste der vorhandenen.
|
||||||
@@ -249,7 +250,7 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
|
|||||||
| `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) |
|
| `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-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul |
|
||||||
| `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen |
|
| `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/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-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) |
|
| `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) |
|
||||||
@@ -257,10 +258,10 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
|
|||||||
| `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen |
|
| `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen |
|
||||||
| `lit/divi-duplicate-node` | schreiben | Knoten duplizieren |
|
| `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/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-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) |
|
| `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 einfügen |
|
| `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 Seite A nach Seite B kopieren |
|
| `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-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/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) |
|
||||||
| `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts |
|
| `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.
|
- 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`.
|
- 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
|
## 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.
|
- **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots.
|
||||||
- Umsetzung mit Settings API, ohne JS-Framework.
|
- Umsetzung mit Settings API, ohne JS-Framework.
|
||||||
|
|
||||||
## 9. Anleitungen für Claude
|
## 9. Anleitungen für den KI-Assistenten
|
||||||
|
|
||||||
### 9.1 Server-Instructions
|
### 9.1 Server-Instructions
|
||||||
|
|
||||||
@@ -401,15 +402,17 @@ Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zei
|
|||||||
- aktive Integrationen und Post-Typen,
|
- 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,
|
- 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`.
|
- 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)`
|
### 9.2 `lit/guide(topic)`
|
||||||
|
|
||||||
Direktes Tool; Anleitungen als Markdown in `docs/guides/`, mit dem Plugin versioniert, per Filter `lit_mcp_guides` erweiterbar.
|
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
|
### 9.3 MCP-Prompts
|
||||||
|
|
||||||
`corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`.
|
`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
|
## 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 | – |
|
| **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 | – |
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
bootstrap="tests/bootstrap.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
colors="true">
|
colors="true">
|
||||||
|
<php>
|
||||||
|
<!-- 1597+ tests with Mockery/Brain Monkey need ~180 MB; the CLI default is 128 MB. -->
|
||||||
|
<ini name="memory_limit" value="512M"/>
|
||||||
|
</php>
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="unit">
|
<testsuite name="unit">
|
||||||
<directory>tests/Unit</directory>
|
<directory>tests/Unit</directory>
|
||||||
|
|||||||
+51
-13
@@ -1,20 +1,20 @@
|
|||||||
=== WP-LIT-MCP ===
|
=== WP-LIT-MCP ===
|
||||||
Contributors: loosenit
|
Contributors: loosenit
|
||||||
Tags: mcp, claude, divi, pods, ai
|
Tags: mcp, ai, divi, pods, oauth
|
||||||
Requires at least: 6.9
|
Requires at least: 6.9
|
||||||
Tested up to: 7.1
|
Tested up to: 7.1
|
||||||
Requires PHP: 8.1
|
Requires PHP: 8.1
|
||||||
Stable tag: 0.3.0
|
Stable tag: 0.4.0
|
||||||
License: GPL-2.0-or-later
|
License: GPL-2.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
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 ==
|
== Description ==
|
||||||
|
|
||||||
WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt:
|
WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (ein MCP-fähiger Client mit OAuth) diese
|
||||||
Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
|
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
|
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.
|
WordPress-Benutzers und wird protokolliert.
|
||||||
|
|
||||||
Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).
|
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
|
* 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,
|
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).
|
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.
|
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
|
* 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
|
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
|
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,
|
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.
|
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
|
Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS, HTML-Attribute,
|
||||||
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben
|
Interaktionen und die Lottie-Quelle von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html.
|
||||||
aktiv.
|
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
|
* Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke
|
||||||
<!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die
|
<!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die
|
||||||
lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie
|
lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie
|
||||||
@@ -64,6 +66,19 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
|
|||||||
Seiten betrifft das praktisch nie (die Antwort warnt dann, dass der Inhalt beim
|
Seiten betrifft das praktisch nie (die Antwort warnt dann, dass der Inhalt beim
|
||||||
Speichern verändert wurde).
|
Speichern verändert wurde).
|
||||||
* Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
|
* Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
|
||||||
|
* 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. Die Bestätigung wird abgelehnt, wenn der Benutzer einen veröffentlichten,
|
||||||
|
privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf.
|
||||||
|
* 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:<ID>}} nutzen Divis eigenen dynamischen Inhalt „<Typ> Link“: die Adresse wird beim
|
||||||
|
Anzeigen ermittelt. Für Entwürfe und private Ziele zeigt Divi Besuchern nur ?page_id=<ID> (ohne Titel).
|
||||||
|
* lit-site-status prüft, ob die von LIT MCP genutzten Divi-Funktionen vorhanden sind, und nennt nach einem
|
||||||
|
Divi-Update fehlende unter problems.
|
||||||
* Pods-Felder: Die Feldrechte von Pods gelten auch über LIT MCP. „Verborgen“ und für den Benutzer gesperrte
|
* Pods-Felder: Die Feldrechte von Pods gelten auch über LIT MCP. „Verborgen“ und für den Benutzer gesperrte
|
||||||
Felder (nur Admins, Rollen- oder Capability-Beschränkung) werden weder angezeigt noch geschrieben;
|
Felder (nur Admins, Rollen- oder Capability-Beschränkung) werden weder angezeigt noch geschrieben;
|
||||||
„schreibgeschützt“ (bzw. gesperrt mit „schreibgeschützt statt verborgen“) sind nur lesbar.
|
„schreibgeschützt“ (bzw. gesperrt mit „schreibgeschützt statt verborgen“) sind nur lesbar.
|
||||||
@@ -92,6 +107,9 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
|
|||||||
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
|
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
|
||||||
* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über
|
* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über
|
||||||
lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
|
lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
|
||||||
|
* 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.
|
||||||
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
|
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
|
||||||
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
|
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
|
||||||
von dort in WordPress wiederherstellbar.
|
von dort in WordPress wiederherstellbar.
|
||||||
@@ -115,7 +133,7 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
|
|||||||
MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
|
MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
|
||||||
bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten
|
bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten
|
||||||
sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht).
|
sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht).
|
||||||
Wer Claude sicher trennen will, widerruft die Verbindungen vorher unter Zugriff.
|
Wer den Assistenten sicher trennen will, widerruft die Verbindungen vorher unter Zugriff.
|
||||||
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website)
|
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website)
|
||||||
sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern.
|
sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern.
|
||||||
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
|
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
|
||||||
@@ -138,13 +156,33 @@ abgeschaltet.
|
|||||||
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
|
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
|
||||||
Tools einer Gruppe auf einmal ab.
|
Tools einer Gruppe auf einmal ab.
|
||||||
|
|
||||||
= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? =
|
= Bleiben vom Assistenten bearbeitete Divi-Seiten im Visual Builder bearbeitbar? =
|
||||||
|
|
||||||
Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem
|
Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem
|
||||||
Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor es weiter ändert.
|
Designsystem. Nach einer Änderung im Builder liest der Assistent die Seite neu, bevor er weiter ändert.
|
||||||
|
|
||||||
|
= 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 ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 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:<ID>}}; 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.
|
||||||
|
* Sicherheit: Interaktionen und die Lottie-Quelle von Modulen nur mit unfiltered_html; gespeicherte Knoten
|
||||||
|
werden beim Kopieren, Einfügen und Speichern für Benutzer ohne unfiltered_html geprüft.
|
||||||
|
|
||||||
= 0.3.0 =
|
= 0.3.0 =
|
||||||
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog,
|
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog,
|
||||||
Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
|
Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ final class ExposurePolicy
|
|||||||
return isset($this->readonlyGroups[$group]);
|
return isset($this->readonlyGroups[$group]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */
|
||||||
|
public function groupWritable(string $group): bool
|
||||||
|
{
|
||||||
|
return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The
|
||||||
|
* group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9).
|
||||||
|
*/
|
||||||
|
public function groupEnabled(string $group): bool
|
||||||
|
{
|
||||||
|
return $this->groupMode($group) !== self::DISABLED;
|
||||||
|
}
|
||||||
|
|
||||||
public function defaultMode(Ability $ability): string
|
public function defaultMode(Ability $ability): string
|
||||||
{
|
{
|
||||||
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class WriteFailed extends RuntimeException
|
|||||||
public readonly ?int $postId,
|
public readonly ?int $postId,
|
||||||
public readonly ?int $revisionId,
|
public readonly ?int $revisionId,
|
||||||
?Throwable $previous,
|
?Throwable $previous,
|
||||||
string $detail,
|
private string $detail,
|
||||||
public readonly ?string $state,
|
public readonly ?string $state,
|
||||||
public readonly ?string $hint,
|
public readonly ?string $hint,
|
||||||
public readonly array $details
|
public readonly array $details
|
||||||
@@ -82,6 +82,28 @@ final class WriteFailed extends RuntimeException
|
|||||||
return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details);
|
return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same failure with $details merged into its client details; a non-null $hint or $state
|
||||||
|
* replaces the current one (see rolledBack()/partial()). For callers that learn about
|
||||||
|
* something kept (e.g. a backup) only after the failure was thrown.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $details
|
||||||
|
*/
|
||||||
|
public function withDetails(array $details, ?string $hint = null, ?string $state = null): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->reason,
|
||||||
|
$this->rolledBack,
|
||||||
|
$this->postId,
|
||||||
|
$this->revisionId,
|
||||||
|
$this->getPrevious(),
|
||||||
|
$this->detail,
|
||||||
|
$state ?? $this->state,
|
||||||
|
$hint ?? $this->hint,
|
||||||
|
array_merge($this->details, $details)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function toError(): WP_Error
|
public function toError(): WP_Error
|
||||||
{
|
{
|
||||||
return $this->rolledBack
|
return $this->rolledBack
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class InstructionsBuilder
|
final class InstructionsBuilder
|
||||||
{
|
{
|
||||||
|
/** Tool group of the Divi Library (LibraryAccess::GROUP); its rule needs a writable group. */
|
||||||
|
private const LIBRARY_GROUP = 'divi-library';
|
||||||
|
|
||||||
/** @var (callable(): list<string>)|null */
|
/** @var (callable(): list<string>)|null */
|
||||||
private $postTypeNames;
|
private $postTypeNames;
|
||||||
|
|
||||||
@@ -63,6 +66,15 @@ final class InstructionsBuilder
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
$library = str_contains(', ' . $active . ',', ', ' . self::LIBRARY_GROUP . ',')
|
||||||
|
&& $this->policy->groupWritable(self::LIBRARY_GROUP)
|
||||||
|
? [
|
||||||
|
'- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global <ID>]) im Bibliothekseintrag '
|
||||||
|
. 'ändern; das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true '
|
||||||
|
. '(lit-guide topic "divi-library").',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return implode("\n", array_merge($lines, [
|
return implode("\n", array_merge($lines, [
|
||||||
'Regeln:',
|
'Regeln:',
|
||||||
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
|
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
|
||||||
@@ -74,6 +86,6 @@ final class InstructionsBuilder
|
|||||||
'- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).',
|
'- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).',
|
||||||
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
|
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
|
||||||
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
|
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
|
||||||
], $divi));
|
], $divi, $library));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Core;
|
namespace LIT\MCP\Integrations\Core;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\DiviCompatibility;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the themes/plugins LIT MCP integrates with.
|
* Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing
|
||||||
|
* (DiviCompatibility).
|
||||||
*/
|
*/
|
||||||
class EnvironmentProbe
|
class EnvironmentProbe
|
||||||
{
|
{
|
||||||
/** @return list<array{id: string, active: bool, version: ?string}> */
|
public function __construct(private ?DiviCompatibility $divi = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
|
||||||
public function components(): array
|
public function components(): array
|
||||||
{
|
{
|
||||||
$theme = wp_get_theme();
|
$theme = wp_get_theme();
|
||||||
@@ -22,16 +29,23 @@ class EnvironmentProbe
|
|||||||
$tecActive = class_exists('Tribe__Events__Main');
|
$tecActive = class_exists('Tribe__Events__Main');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion],
|
[
|
||||||
|
'id' => 'divi',
|
||||||
|
'active' => $isDivi,
|
||||||
|
'version' => $diviVersion,
|
||||||
|
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'id' => 'pods',
|
'id' => 'pods',
|
||||||
'active' => defined('PODS_VERSION'),
|
'active' => defined('PODS_VERSION'),
|
||||||
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||||||
|
'problems' => [],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'the-events-calendar',
|
'id' => 'the-events-calendar',
|
||||||
'active' => $tecActive,
|
'active' => $tecActive,
|
||||||
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
|
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
|
||||||
|
'problems' => [],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ final class GuideAbility implements Ability
|
|||||||
{
|
{
|
||||||
private const DEFAULT_TOPIC = 'overview';
|
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;
|
||||||
|
|
||||||
public function __construct(private GuideRepository $guides)
|
public function __construct(private GuideRepository $guides)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -32,7 +38,8 @@ final class GuideAbility implements Ability
|
|||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.';
|
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).';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function inputSchema(): array
|
public function inputSchema(): array
|
||||||
@@ -42,7 +49,7 @@ final class GuideAbility implements Ability
|
|||||||
'properties' => [
|
'properties' => [
|
||||||
'topic' => [
|
'topic' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'description' => 'Thema, z. B. "overview" oder "divi-shortform".',
|
'description' => 'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'additionalProperties' => false,
|
'additionalProperties' => false,
|
||||||
@@ -83,6 +90,10 @@ final class GuideAbility implements Ability
|
|||||||
? $input['topic']
|
? $input['topic']
|
||||||
: self::DEFAULT_TOPIC;
|
: self::DEFAULT_TOPIC;
|
||||||
$topics = $this->guides->topics();
|
$topics = $this->guides->topics();
|
||||||
|
if ($topic === self::ALL) {
|
||||||
|
return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)];
|
||||||
|
}
|
||||||
|
|
||||||
$content = $this->guides->get($topic);
|
$content = $this->guides->get($topic);
|
||||||
|
|
||||||
if ($content === null) {
|
if ($content === null) {
|
||||||
@@ -94,4 +105,44 @@ final class GuideAbility implements Ability
|
|||||||
|
|
||||||
return ['topic' => $topic, 'topics' => $topics, 'content' => $content];
|
return ['topic' => $topic, 'topics' => $topics, 'content' => $content];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names
|
||||||
|
* the topics left out.
|
||||||
|
*
|
||||||
|
* @param list<string> $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ final class SiteStatusAbility implements Ability
|
|||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
|
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
|
||||||
. 'erkannte Integrationen (Divi, Pods, The Events Calendar).';
|
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
|
||||||
|
. 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function inputSchema(): array
|
public function inputSchema(): array
|
||||||
|
|||||||
@@ -141,6 +141,30 @@ final class Block extends Container
|
|||||||
return clone $this;
|
return clone $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
|
||||||
public function __clone()
|
public function __clone()
|
||||||
{
|
{
|
||||||
foreach ($this->parts as $position => $part) {
|
foreach ($this->parts as $position => $part) {
|
||||||
|
|||||||
@@ -93,12 +93,41 @@ abstract class Container
|
|||||||
return $index;
|
return $index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
* and so do opaque blocks (global instances keep Divi's bytes).
|
||||||
|
*/
|
||||||
|
public function unifyLineBreaks(): void
|
||||||
|
{
|
||||||
|
if ($this instanceof Block && $this->isOpaque()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Called after the list of children changed. */
|
/** Called after the list of children changed. */
|
||||||
protected function touched(): void
|
protected function touched(): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */
|
/** insertChild()/removeChild()/replaceChild() are the tools' structural edits – never on an opaque block. */
|
||||||
private function guardMutable(): void
|
private function guardMutable(): void
|
||||||
{
|
{
|
||||||
if ($this instanceof Block && $this->isOpaque()) {
|
if ($this instanceof Block && $this->isOpaque()) {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
|
||||||
|
* divi/global-layout block whose content Divi takes from the library entry at render time (F7).
|
||||||
|
* Instances stay opaque for the page tools; edits go to the library entry. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class GlobalInstance
|
||||||
|
{
|
||||||
|
public const BLOCK = 'divi/global-layout';
|
||||||
|
|
||||||
|
/** Block name the node counts as for structure rules: an instance counts as its root type. */
|
||||||
|
public static function effectiveName(Block $block): string
|
||||||
|
{
|
||||||
|
if ($block->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": "<id>" 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, and so does an instance
|
||||||
|
* without a usable blockName. 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'] ?? [];
|
||||||
|
try {
|
||||||
|
$block = $block->copyAs($name, is_array($local) ? $local : []);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return $block; // Stored blockName is no block name: the instance stays opaque.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
|
||||||
|
* frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class LibraryContent
|
||||||
|
{
|
||||||
|
public const WRAPPER = 'divi/placeholder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
|
||||||
|
* Never used for the content a tool edits in place – that keeps its bytes.
|
||||||
|
*
|
||||||
|
* @throws MalformedMarkup
|
||||||
|
*/
|
||||||
|
public static function load(Parser $parser, string $content): Document
|
||||||
|
{
|
||||||
|
$document = $parser->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 '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Block> 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<Block> $before
|
||||||
|
* @param list<Block> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@ final class NodeSummary
|
|||||||
|
|
||||||
public static function label(Block $block): string
|
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 === '') {
|
if (! is_string($label) || $label === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,29 @@ final class NodeSummary
|
|||||||
return '';
|
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<string> */
|
/** @return list<string> */
|
||||||
public static function presetIds(Block $block): array
|
public static function presetIds(Block $block): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ final class Outline
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
|
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
|
||||||
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}>
|
* presets: list<string>, 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
|
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) {
|
foreach ($rows as $row) {
|
||||||
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
|
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
|
||||||
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
|
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
|
||||||
|
$line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';
|
||||||
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
|
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
|
||||||
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
|
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
|
||||||
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
|
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
|
||||||
@@ -127,15 +129,23 @@ final class Outline
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
|
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
|
||||||
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}
|
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
|
||||||
|
* global_id: ?string}
|
||||||
*/
|
*/
|
||||||
private function row(Block $block, string $id, int $depth): array
|
private function row(Block $block, string $id, int $depth): array
|
||||||
{
|
{
|
||||||
$editable = $block->isDivi() && ! $block->isOpaque();
|
$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 [
|
return [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'type' => $block->isDivi() ? $block->shortName() : $block->name(),
|
'type' => $type,
|
||||||
'label' => NodeSummary::label($block),
|
'label' => NodeSummary::label($block),
|
||||||
'excerpt' => NodeSummary::excerpt($block),
|
'excerpt' => NodeSummary::excerpt($block),
|
||||||
'info' => $editable ? NodeSummary::info($block) : '',
|
'info' => $editable ? NodeSummary::info($block) : '',
|
||||||
@@ -147,6 +157,7 @@ final class Outline
|
|||||||
'editable' => $editable,
|
'editable' => $editable,
|
||||||
'depth' => $depth,
|
'depth' => $depth,
|
||||||
'truncated' => false,
|
'truncated' => false,
|
||||||
|
'global_id' => $global['id'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class AttrWriter
|
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:
|
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entries {{link:<ID>}} 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;
|
||||||
|
}
|
||||||
@@ -12,10 +12,10 @@ defined('ABSPATH') || exit;
|
|||||||
/**
|
/**
|
||||||
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||||||
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
||||||
* {{post:title|…}}, {{field:<meta_key>}}. A placeholder must be the whole value (D9); unknown
|
* {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
|
||||||
* names fail with the list of existing ones, labels two entries share fail with their IDs
|
* A placeholder must be the whole value (D9); unknown names fail with the list of existing ones,
|
||||||
* (ruling S13a, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef
|
* labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor).
|
||||||
* wherever it is written. Pure PHP.
|
* The result is still checked by VariableRef wherever it is written. Pure PHP.
|
||||||
*/
|
*/
|
||||||
final class Placeholders
|
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',
|
'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, '{{')) {
|
if (str_contains($value, '{{')) {
|
||||||
throw new InvalidArgumentException(
|
throw new InvalidArgumentException(
|
||||||
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
|
'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),
|
'var' => $this->variable($name),
|
||||||
'font' => $this->font($name),
|
'font' => $this->font($name),
|
||||||
'post' => $this->post($name),
|
'post' => $this->post($name),
|
||||||
|
'link' => $this->link($name),
|
||||||
default => $this->field($name),
|
default => $this->field($name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -152,6 +154,36 @@ final class Placeholders
|
|||||||
return VariableRef::serialize('content', $option, $settings);
|
return VariableRef::serialize('content', $option, $settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> 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
|
private function field(string $name): string
|
||||||
{
|
{
|
||||||
if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) {
|
if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) {
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E6 (security audit M1, R2 finding 1, rulings P2/P3): a stored node that moves to another post –
|
||||||
|
* page → page (lit/divi-node-copy), library → page (lit/library-insert), page → library
|
||||||
|
* (lit/library-save) – keeps its bytes. Stored nodes never passed ValueCleaner, and Divi saves bypass
|
||||||
|
* kses (D7), so without this check an editor could carry an administrator's markup into a post of
|
||||||
|
* their own. For users without unfiltered_html the node is REFUSED (never cleaned) when anything in
|
||||||
|
* its subtree – the snapshot and the localAttrs of global instances included (F7) – is
|
||||||
|
* - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML,
|
||||||
|
* Divi 4 shortcodes),
|
||||||
|
* - an opaque block other than a readable divi/global-layout instance (undecodable attributes,
|
||||||
|
* a globalModule reference on a plain block, an instance without a digits-only globalModule,
|
||||||
|
* with more than one or an escaped globalModule in its opener, or without a usable
|
||||||
|
* blockName/localAttrs),
|
||||||
|
* - a code module,
|
||||||
|
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
|
||||||
|
* also when the builder-only key sits below the state value,
|
||||||
|
* - a string with control characters (S32), rich text or code that the sanitizer would change
|
||||||
|
* (not kses-stable), a plain-text string with markup that is not kses-stable, a URL the
|
||||||
|
* sanitizer would change (plain relative links excepted, ruling P18), or a key inside a value
|
||||||
|
* that is no plain identifier (S13) – routed exactly like ValueCleaner (kindAt(), VALUE_KEY),
|
||||||
|
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
|
||||||
|
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
|
||||||
|
* Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class StoredNodeGuard
|
||||||
|
{
|
||||||
|
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
|
||||||
|
|
||||||
|
/** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */
|
||||||
|
private const LIBRARY_ID = '/^[0-9]+$/D';
|
||||||
|
|
||||||
|
/** Divi's render (BlockParser.php:838) takes the first textual match of this in the opener. */
|
||||||
|
private const RENDERED_ID = '/"globalModule":"([0-9]+)"/';
|
||||||
|
|
||||||
|
/** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, 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<string> 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<string> $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<string, mixed>|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<string> $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<string> $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<string> $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<array-key, mixed> $settings
|
||||||
|
* @return array<array-key, mixed> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
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\NodeIndex;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
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,
|
* 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
|
* 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
|
final class TreeEditor
|
||||||
{
|
{
|
||||||
@@ -79,7 +81,7 @@ final class TreeEditor
|
|||||||
public function insert(array $position, array $blocks): ?string
|
public function insert(array $position, array $blocks): ?string
|
||||||
{
|
{
|
||||||
foreach ($blocks as $block) {
|
foreach ($blocks as $block) {
|
||||||
$violation = $this->rules->violation($position['parent'], $block->name());
|
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
return $violation;
|
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) {
|
if ($violation !== null) {
|
||||||
return $violation;
|
return $violation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ final class ValueCleaner
|
|||||||
/** Keys whose string values are URLs when the catalog names no kind (ruling S3). */
|
/** Keys whose string values are URLs when the catalog names no kind (ruling S3). */
|
||||||
private const URL_KEY = '/(url|src|link|href|image)$/i';
|
private const URL_KEY = '/(url|src|link|href|image)$/i';
|
||||||
|
|
||||||
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */
|
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */
|
||||||
private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
|
public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
|
||||||
|
|
||||||
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
|
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
|
||||||
private const URL_SEGMENT = '/url$/i';
|
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
|
* 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,
|
* 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(
|
public function __construct(
|
||||||
private ModuleCatalog $catalog,
|
private ModuleCatalog $catalog,
|
||||||
@@ -93,7 +98,17 @@ final class ValueCleaner
|
|||||||
*/
|
*/
|
||||||
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
|
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()) {
|
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
|
||||||
throw new InvalidArgumentException(sprintf(
|
throw new InvalidArgumentException(sprintf(
|
||||||
'"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar '
|
'"%s" (eigenes HTML, CSS, HTML-Attribute, Interaktionen oder Lottie-Quelle) ist nur im '
|
||||||
. '(oder mit dem Recht unfiltered_html).',
|
. 'Divi-Builder änderbar (oder mit dem Recht unfiltered_html).',
|
||||||
$path
|
$path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ defined('ABSPATH') || exit;
|
|||||||
* settings hue/saturation/lightness/opacity, numbers only.
|
* settings hue/saturation/lightness/opacity, numbers only.
|
||||||
* - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
|
* - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
|
||||||
* (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content
|
* (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_<type> ({{link:…}},
|
||||||
|
* E13a) only in URL fields.
|
||||||
* A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
|
* A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
|
||||||
*/
|
*/
|
||||||
final class VariableRef
|
final class VariableRef
|
||||||
@@ -75,6 +76,25 @@ final class VariableRef
|
|||||||
return $type === 'color' || $type === 'content' ? $type : null;
|
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<array-key, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|stdClass $settings */
|
/** @param array<string, mixed>|stdClass $settings */
|
||||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
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
|
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);
|
$fields = $this->content->fieldsFor($name);
|
||||||
$clean = [];
|
$clean = [];
|
||||||
foreach ($settings as $key => $value) {
|
foreach ($settings as $key => $value) {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class DiviDesignSystemAbility implements Ability
|
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)
|
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 '
|
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:…}}, '
|
. '-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
|
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
|
public function outputSchema(): ?array
|
||||||
@@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability
|
|||||||
public function execute(array $input): mixed
|
public function execute(array $input): mixed
|
||||||
{
|
{
|
||||||
$design = $this->reader->read();
|
$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": "<Name>".',
|
||||||
|
'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 [
|
return [
|
||||||
'colors' => array_map(
|
'colors' => in_array('colors', $only, true) ? array_map(
|
||||||
static fn (array $color): array => $color + [
|
static fn (array $color): array => $color + [
|
||||||
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
|
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
|
||||||
],
|
],
|
||||||
$design->colors()
|
$design->colors()
|
||||||
),
|
) : [],
|
||||||
'variables' => array_map(
|
'variables' => in_array('variables', $only, true) ? array_map(
|
||||||
static fn (array $variable): array => $variable
|
static fn (array $variable): array => $variable
|
||||||
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'],
|
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'],
|
||||||
$design->variables()
|
$design->variables()
|
||||||
),
|
) : [],
|
||||||
'fonts' => $design->fonts(),
|
'fonts' => $design->fonts(),
|
||||||
'sizes' => $design->sizes(),
|
'sizes' => $design->sizes(),
|
||||||
'presets' => $design->presets(),
|
'presets' => in_array('presets', $only, true) ? $presets : [],
|
||||||
'notes' => [
|
'notes' => $notes,
|
||||||
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
|
|
||||||
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
|
||||||
|
* functions, the library post type and taxonomies, the instance block type. lit-site-status shows
|
||||||
|
* the failed checks, so a Divi update that moves one of them is visible before a tool reports
|
||||||
|
* "unsupported".
|
||||||
|
*/
|
||||||
|
final class DiviCompatibility
|
||||||
|
{
|
||||||
|
/** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
|
||||||
|
public function __construct(private ?array $checks = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> 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<string, callable(): bool> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,23 @@ namespace LIT\MCP\Integrations\Divi;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
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\DesignSystemReader;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
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\DiviComposition;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
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\DiviInsertAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
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\DiviNodeGetAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
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\DiviSaver;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
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\DiviCatalogAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
@@ -39,11 +49,29 @@ final class DiviIntegration implements Integration
|
|||||||
{
|
{
|
||||||
private DiviModules $modules;
|
private DiviModules $modules;
|
||||||
private DesignSystemReader $design;
|
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->modules = new DiviModules();
|
||||||
$this->design = new DesignSystemReader();
|
$this->design = new DesignSystemReader();
|
||||||
|
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function id(): string
|
public function id(): string
|
||||||
@@ -80,9 +108,8 @@ final class DiviIntegration implements Integration
|
|||||||
|
|
||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
{
|
{
|
||||||
$loader = new DiviPageLoader($this->schema);
|
$loader = $this->loader();
|
||||||
$composition = new DiviComposition($this->modules, $this->design);
|
$writer = $this->writer($loader);
|
||||||
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new DiviOutlineAbility($loader, $this->design),
|
new DiviOutlineAbility($loader, $this->design),
|
||||||
@@ -95,6 +122,45 @@ final class DiviIntegration implements Integration
|
|||||||
new DiviDeleteAbility($writer),
|
new DiviDeleteAbility($writer),
|
||||||
new DiviDuplicateNodeAbility($writer),
|
new DiviDuplicateNodeAbility($writer),
|
||||||
new DiviReplaceTextAbility($this->schema, $loader, $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
use WP_Term;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
|
||||||
|
* layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
|
||||||
|
* writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
|
||||||
|
* GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
|
||||||
|
* gone. find() filters by the scope term only, so an entry Divi counts as global through its D4
|
||||||
|
* meta fallback can show up under scope "normal" (preflight M-12, accepted). Library entries are
|
||||||
|
* always "publish" (F2). Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class DiviLibraryStore
|
||||||
|
{
|
||||||
|
public const POST_TYPE = 'et_pb_layout';
|
||||||
|
|
||||||
|
public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';
|
||||||
|
|
||||||
|
public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];
|
||||||
|
|
||||||
|
/** Taxonomies every new entry needs (et_pb_submit_layout). */
|
||||||
|
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
|
||||||
|
|
||||||
|
/** Client text when the terms or meta of a new entry could not be written. */
|
||||||
|
public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.';
|
||||||
|
|
||||||
|
public function isGlobal(int $id): bool
|
||||||
|
{
|
||||||
|
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
|
||||||
|
return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array('global', $this->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<string> */
|
||||||
|
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<string> */
|
||||||
|
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<WP_Post>
|
||||||
|
*/
|
||||||
|
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<string>, tags: list<string>, 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<string>
|
||||||
|
*/
|
||||||
|
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<WP_Term> */
|
||||||
|
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))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi 5.2.1 builder/core.php:1128)
|
||||||
|
* answers for the current user's role, "dangerous" options (theme_builder,
|
||||||
|
* read_dynamic_content_custom_fields) default to off for every role but administrator. When
|
||||||
|
* Divi no longer has the function, nothing is allowed – DiviCompatibility reports it.
|
||||||
|
* Not final so tests can pass a check.
|
||||||
|
*/
|
||||||
|
class DiviRoles
|
||||||
|
{
|
||||||
|
private const LABELS = [
|
||||||
|
'divi_library' => '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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole
|
||||||
|
* change validated and before anything is saved:
|
||||||
|
* - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type;
|
||||||
|
* - a global entry – and any entry that pages show as an instance, global scope or not (audit L2:
|
||||||
|
* Divi renders every referenced entry) – changes every page that shows it at once (F7): without
|
||||||
|
* confirm: true nothing is saved and the answer lists those pages (a dry run of the fully
|
||||||
|
* validated change); with it the
|
||||||
|
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
|
||||||
|
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
|
||||||
|
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
|
||||||
|
* not edit_post one of the published, private or scheduled posts that show the entry (rulings
|
||||||
|
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may
|
||||||
|
* not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above
|
||||||
|
* GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead).
|
||||||
|
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
|
||||||
|
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
|
||||||
|
*/
|
||||||
|
final class GlobalEdits
|
||||||
|
{
|
||||||
|
/** Refusals name at most this many page IDs. */
|
||||||
|
private const LISTED_IDS = 10;
|
||||||
|
|
||||||
|
/** @var Closure(WP_Post): int */
|
||||||
|
private Closure $backup;
|
||||||
|
|
||||||
|
/** @var Closure(): bool */
|
||||||
|
private Closure $trashEnabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
|
||||||
|
* @param (Closure(): bool)|null $trashEnabled Whether the trash keeps the backup; null = EMPTY_TRASH_DAYS.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private GlobalUsages $usages,
|
||||||
|
?Closure $backup = null,
|
||||||
|
private Parser $parser = new Parser(),
|
||||||
|
?Closure $trashEnabled = null
|
||||||
|
) {
|
||||||
|
$this->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<string, mixed> $input
|
||||||
|
* @return array{scope: SaveScope, details: array<string, mixed>}|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<int> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
|
||||||
|
* living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5
|
||||||
|
* instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come
|
||||||
|
* from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the
|
||||||
|
* rights of the caller are then checked in chunks with a bounded post cache:
|
||||||
|
* - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4);
|
||||||
|
* - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when
|
||||||
|
* readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option
|
||||||
|
* (audit L6); above HARD_CAP live posts the edit is refused as a whole.
|
||||||
|
* Not final so tests can mock rows().
|
||||||
|
*/
|
||||||
|
class GlobalUsages
|
||||||
|
{
|
||||||
|
/** At most this many readable posts are listed. */
|
||||||
|
public const LIMIT = 50;
|
||||||
|
|
||||||
|
/** Above this many live posts a confirmed edit is refused without checking each of them (audit L1). */
|
||||||
|
public const HARD_CAP = 2000;
|
||||||
|
|
||||||
|
/** At most this many posts are checked for read_post (the listing and hidden_count). */
|
||||||
|
public const CHECK_CAP = 2000;
|
||||||
|
|
||||||
|
/** Posts whose rights are checked with one primed cache. */
|
||||||
|
public const CHUNK = 200;
|
||||||
|
|
||||||
|
/** Candidates whose content is read and parsed per call; the rest count as references (ruling P23). */
|
||||||
|
public const PARSE_CAP = 5000;
|
||||||
|
|
||||||
|
/** Candidate contents read per query (bounded memory, audit L1). */
|
||||||
|
public const CONTENT_CHUNK = 50;
|
||||||
|
|
||||||
|
/** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */
|
||||||
|
public const LIVE = ['publish', 'private', 'future'];
|
||||||
|
|
||||||
|
/** Every post that exists for the user: listed in the dry run. */
|
||||||
|
private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')";
|
||||||
|
|
||||||
|
/** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */
|
||||||
|
public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template'];
|
||||||
|
|
||||||
|
private DiviRoles $roles;
|
||||||
|
|
||||||
|
public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null)
|
||||||
|
{
|
||||||
|
$this->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<array{id: int, status: string, type: string}>
|
||||||
|
*/
|
||||||
|
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<array{id: int, status: string, type: string}> */
|
||||||
|
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<int> $ids
|
||||||
|
* @return array<int, string> 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
|
||||||
|
* edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
|
||||||
|
* builder routes do not check that, F9). The Divi tools reach library entries only through
|
||||||
|
* item(); every other ID goes on to PostGuard unchanged.
|
||||||
|
* Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class LibraryAccess
|
||||||
|
{
|
||||||
|
/** Tool group "Divi-Bibliothek" (Tools tab); its switches gate library entries (E9, ruling P9). */
|
||||||
|
public const GROUP = 'divi-library';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
|
||||||
|
* library (group "Divi-Bibliothek" not read-only or disabled); null = always.
|
||||||
|
* @param (Closure(): bool)|null $visible Whether library entries exist for this connection at all
|
||||||
|
* (group "Divi-Bibliothek" not disabled, ruling P9); null = always.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private DiviLibraryStore $store,
|
||||||
|
private DiviRoles $roles,
|
||||||
|
private ?Closure $writable = null,
|
||||||
|
private ?Closure $visible = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): DiviLibraryStore
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-insert (spec §5.7, E5): a Divi Library entry into a Divi page – as the global instance
|
||||||
|
* the Visual Builder writes (GlobalInstance::of; Divi renders the entry's current content, F7) or
|
||||||
|
* as an independent copy of its roots (a global entry deglobalized like Divi's /load, F10). The
|
||||||
|
* target works like lit-divi-insert (tree_rev lock, StructureRules by effective name, all or
|
||||||
|
* nothing, DiviWriter); the entry is read through LibraryAccess::item() (ruling P5), never nests
|
||||||
|
* instances (ruling P11), and its nodes pass StoredNodeGuard (E6). Needs add_library (E3) and
|
||||||
|
* follows lit-divi-insert's Tools-tab state (audit M2).
|
||||||
|
*/
|
||||||
|
final class LibraryInsertAbility extends DiviWriteAbility
|
||||||
|
{
|
||||||
|
private const MODES = ['copy', 'global'];
|
||||||
|
|
||||||
|
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
|
||||||
|
public function __construct(
|
||||||
|
DiviWriter $writer,
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private ?Closure $diviWritable = null,
|
||||||
|
private Parser $parser = new Parser()
|
||||||
|
) {
|
||||||
|
parent::__construct($writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/library-insert';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Aus der Divi-Bibliothek einfügen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Fügt einen Eintrag der Divi-Bibliothek in eine Divi-Seite ein: mode "global" als verknüpfte Instanz '
|
||||||
|
. '(der Inhalt kommt immer aus der Bibliothek; Änderungen dort wirken auf allen Seiten) oder mode "copy" '
|
||||||
|
. 'als unabhängige Kopie. Standard: global für globale Einträge, sonst copy. Einträge über '
|
||||||
|
. 'lit/library-list; Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans '
|
||||||
|
. 'Seitenende. tree_rev ist Pflicht; alles oder nichts.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => '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<Block>|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<Block> $roots
|
||||||
|
* @return list<Block>|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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
|
||||||
|
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
||||||
|
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
||||||
|
* Divi 5 builder, like the Divi group.
|
||||||
|
*/
|
||||||
|
final class LibraryIntegration implements Integration
|
||||||
|
{
|
||||||
|
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DesignSystemReader $design,
|
||||||
|
private DiviWriter $writer,
|
||||||
|
private DiviSaver $saver,
|
||||||
|
private ChangeLog $log,
|
||||||
|
private DiviModules $modules,
|
||||||
|
private ?Closure $diviWritable = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return LibraryAccess::GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Divi-Bibliothek', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return DiviModules::isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new LibraryListAbility(
|
||||||
|
$this->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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title, terms and meta of a new library entry, like Divi's et_pb_submit_layout() writes them
|
||||||
|
* (builder/shortcode-core.php:654-734; compared with the real builder entries, fixtures stage3):
|
||||||
|
* terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag;
|
||||||
|
* meta _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
|
||||||
|
* _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
|
||||||
|
* "[object Object]" there (finding 5). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class LibraryItemSpec
|
||||||
|
{
|
||||||
|
private const MODULE_CATEGORIES = ['module', 'fullwidth-module'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $categories layout_category term IDs.
|
||||||
|
* @param list<int> $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<int> $categories
|
||||||
|
* @param list<int> $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<string, list<int|string>> 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<string, string> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
|
||||||
|
* of published entries (filters, cursor); with id one entry with its node outline, tree_rev and
|
||||||
|
* the pages that show it as an instance (global entries always, others when any page does, L2).
|
||||||
|
* Rights: LibraryAccess (edit_posts + divi_library; every listed entry also edit_post – others are
|
||||||
|
* left out, without title or count). With id,
|
||||||
|
* everything but a library entry the user may read answers like a missing ID (ruling P12).
|
||||||
|
*/
|
||||||
|
final class LibraryListAbility implements Ability
|
||||||
|
{
|
||||||
|
/** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */
|
||||||
|
public const MAX_SCAN = 500;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DesignSystemReader $design,
|
||||||
|
private GlobalUsages $usages
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/library-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Divi-Bibliothek auflisten';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
|
||||||
|
. 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
|
||||||
|
. 'im Detail – Knoten-Übersicht, tree_rev und die Seiten, die ihn als globales Modul zeigen '
|
||||||
|
. '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => '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<string, string|int> $filters
|
||||||
|
* @return array{items: list<array<string, mixed>>, next_cursor: ?string, notes?: list<string>}
|
||||||
|
*/
|
||||||
|
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<string, mixed> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Term;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
|
||||||
|
* normal or global (spec §5.7, E4). The entry gets the node's stored bytes in the
|
||||||
|
* divi/placeholder frame (LibraryContent::wrap, line breaks between blocks unified like
|
||||||
|
* LibraryContent::load), saved through DiviSaver::saveNew with SaveScope::LibraryItem (the one
|
||||||
|
* content write, D7; no Divi flags). The node is checked by StoredNodeGuard (E6) because it moves
|
||||||
|
* to another post. The source is never written – linking it to the entry is
|
||||||
|
* lit/library-make-global (Etappe 3b). All or nothing through NewDraft (ruling P6): any failure
|
||||||
|
* after the insert deletes the new entry, a failed delete is an honest partial_write.
|
||||||
|
*/
|
||||||
|
final class LibrarySaveAbility implements Ability
|
||||||
|
{
|
||||||
|
private const TYPE_LABELS = ['section' => '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<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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<string, mixed> $input */
|
||||||
|
private static function isGlobal(array $input): bool
|
||||||
|
{
|
||||||
|
return ($input['scope'] ?? 'normal') === 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int>|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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether post content shows library entry $id (rulings P23, P24), fail closed:
|
||||||
|
* - Divi 5: the textual "globalModule":"<digits>" 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+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
|
||||||
|
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\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<string, string>|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<array-key, mixed> $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<array-key, mixed> $attrs
|
||||||
|
* @return list<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The posts that show one library entry, as the caller may see them (GlobalUsages::report()):
|
||||||
|
* readable posts by ID and title, unreadable ones only as counts (audit L4). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class UsageReport
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $count Every post that shows the entry.
|
||||||
|
* @param list<array{post_id: int, title: string, status: string, type: string}> $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<int> $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<string, mixed> 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<string, mixed> 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] : []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
|||||||
|
|
||||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
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\MediaResolver;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
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\StructureRules;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||||
@@ -45,7 +47,8 @@ class DiviComposition
|
|||||||
private DesignSystemReader $design,
|
private DesignSystemReader $design,
|
||||||
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
|
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
|
||||||
private MediaResolver $media = new WpMediaResolver(),
|
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
|
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). */
|
/** 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());
|
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
|
public function editor(): TreeEditor
|
||||||
{
|
{
|
||||||
return new TreeEditor($this->rules());
|
return new TreeEditor($this->rules());
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility
|
|||||||
'items' => ['type' => 'string'],
|
'items' => ['type' => 'string'],
|
||||||
'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).',
|
'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'],
|
'required' => ['post_id', 'tree_rev', 'ids'],
|
||||||
'additionalProperties' => false,
|
'additionalProperties' => false,
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\JsonMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
|
||||||
|
* (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
|
||||||
|
* no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||||||
|
* the fields of the type usable as {{field:<key>}}.
|
||||||
|
*
|
||||||
|
* 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:<ID>}}) bzw. dem Namen für die Vollform, '
|
||||||
|
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} 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":"<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:<ID>}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/divi-node-copy (spec §5.7, E6): a node with everything inside from one Divi entry (page or
|
||||||
|
* library entry) to a position in another. The copy keeps its bytes – global instances stay linked –
|
||||||
|
* and passes StoredNodeGuard of the target (builder-only values never move to another post for users
|
||||||
|
* without unfiltered_html). Source: edit rights via DiviPageLoader (PostGuard / LibraryAccess, P9
|
||||||
|
* hiding) and its own source_tree_rev, because path IDs are only valid for the state they were read
|
||||||
|
* in; the source is only read, so it needs no lock. Target: like lit-divi-insert (tree_rev lock,
|
||||||
|
* StructureRules, all or nothing, DiviWriter::commit with GlobalEdits – a global target entry never
|
||||||
|
* gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). A library entry
|
||||||
|
* as source is an insert from the library (E3, audit L5): it needs LibraryAccess::canInsert()
|
||||||
|
* (add_library, writable group "Divi-Bibliothek") and follows lit-library-insert's Tools-tab state.
|
||||||
|
*/
|
||||||
|
final class DiviNodeCopyAbility extends DiviWriteAbility
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always.
|
||||||
|
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled (gates
|
||||||
|
* library entries as source); null = always.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
DiviWriter $writer,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private ?Closure $diviWritable = null,
|
||||||
|
private ?Closure $libraryInsertable = null
|
||||||
|
) {
|
||||||
|
parent::__construct($writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/divi-node-copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Divi-Knoten kopieren';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Kopiert einen Divi-Knoten samt Inhalt von einem Eintrag (source_post_id, source_id und '
|
||||||
|
. 'source_tree_rev aus lit-divi-outline der Quelle) an eine Position eines anderen Eintrags (post_id, '
|
||||||
|
. 'tree_rev, parent/before/after). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Quelle '
|
||||||
|
. 'und Ziel dürfen auch Bibliothekseinträge sein. Alles oder nichts.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability
|
|||||||
],
|
],
|
||||||
'builder_version' => ['type' => ['string', 'null']],
|
'builder_version' => ['type' => ['string', 'null']],
|
||||||
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
|
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
'global' => ['type' => ['object', 'null']],
|
||||||
],
|
],
|
||||||
'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'],
|
'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();
|
$editable = $block->isDivi() && ! $block->isOpaque();
|
||||||
$version = $block->attrs()['builderVersion'] ?? null;
|
$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 [
|
return [
|
||||||
'post_id' => (int) $page->post->ID,
|
'post_id' => (int) $page->post->ID,
|
||||||
'tree_rev' => $page->treeRev(),
|
'tree_rev' => $page->treeRev(),
|
||||||
@@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability
|
|||||||
'children' => $editable
|
'children' => $editable
|
||||||
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
|
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
|
||||||
: [],
|
: [],
|
||||||
|
'global' => $global,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability
|
|||||||
'type' => 'boolean',
|
'type' => 'boolean',
|
||||||
'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.',
|
'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.',
|
||||||
],
|
],
|
||||||
|
'global_id' => [
|
||||||
|
'type' => ['string', 'null'],
|
||||||
|
'description' => 'Bibliotheks-ID eines globalen Moduls.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'required' => [
|
'required' => [
|
||||||
'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth',
|
'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)) {
|
if (in_array(false, array_column($rows, 'editable'), true)) {
|
||||||
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
$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 <ID>] im Bibliothekseintrag '
|
||||||
|
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $hints;
|
return $hints;
|
||||||
|
|||||||
@@ -21,10 +21,17 @@ final class DiviPage
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly WP_Post $post,
|
public readonly WP_Post $post,
|
||||||
public readonly Document $document,
|
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
|
public function treeRev(): string
|
||||||
{
|
{
|
||||||
return Rev::of((string) $this->post->post_content);
|
return Rev::of((string) $this->post->post_content);
|
||||||
@@ -40,7 +47,10 @@ final class DiviPage
|
|||||||
|
|
||||||
public function editUrl(): string
|
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). */
|
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
|
||||||
|
|||||||
@@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
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\PostGuard;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Log;
|
use LIT\MCP\Log;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
|
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
|
||||||
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint –
|
* entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
|
||||||
* spec §3.4: Divi 4 content is kept, never edited.
|
* shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
|
||||||
|
* is kept, never edited.
|
||||||
*/
|
*/
|
||||||
final class DiviPageLoader
|
final class DiviPageLoader
|
||||||
{
|
{
|
||||||
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
|
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.';
|
. 'Ä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). */
|
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
|
||||||
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
|
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) {
|
if ($post instanceof WP_Error) {
|
||||||
return $post;
|
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. */
|
/** 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
|
* Additional checks for writing: the type must use the Divi builder (library entries always do),
|
||||||
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
|
* 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
|
public function writable(DiviPage $page): ?WP_Error
|
||||||
{
|
{
|
||||||
$post = $page->post;
|
$post = $page->post;
|
||||||
if (! $this->schema->diviEnabled((string) $post->post_type)) {
|
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||||||
return Errors::unsupported(
|
return Errors::unsupported(
|
||||||
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
|
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).'
|
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ defined('ABSPATH') || exit;
|
|||||||
*
|
*
|
||||||
* Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is
|
* 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
|
* 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
|
* it is written; DiviWriter::commit() 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
|
* log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never
|
||||||
* and reported there, the others go on. Inserted text is cleaned by the field kind through
|
* 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.
|
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
|
||||||
*/
|
*/
|
||||||
final class DiviReplaceTextAbility implements Ability
|
final class DiviReplaceTextAbility implements Ability
|
||||||
@@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
$change->record($block, 'updated');
|
$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 + [
|
return $base + [
|
||||||
'ok' => true,
|
'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<string, mixed>
|
||||||
|
*/
|
||||||
|
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
|
* Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's
|
||||||
* own composition (ruling S22).
|
* own composition (ruling S22).
|
||||||
|
|||||||
@@ -40,16 +40,20 @@ defined('ABSPATH') || exit;
|
|||||||
* those hooks except the content itself (already cleaned); anything a third-party hook writes
|
* 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
|
* 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
|
* 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):
|
* 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
|
* 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
|
* 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
|
* DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first
|
||||||
* the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
|
* 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
|
* 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.
|
* 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).
|
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
|
||||||
* Not final so abilities can be tested with a mock.
|
* Not final so abilities can be tested with a mock.
|
||||||
*/
|
*/
|
||||||
@@ -64,7 +68,7 @@ class DiviSaver
|
|||||||
* @return array{revision_id: ?int, warnings: list<string>}
|
* @return array{revision_id: ?int, warnings: list<string>}
|
||||||
* @throws WriteFailed
|
* @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;
|
$postId = (int) $post->ID;
|
||||||
$revisionId = null;
|
$revisionId = null;
|
||||||
@@ -90,6 +94,7 @@ class DiviSaver
|
|||||||
}
|
}
|
||||||
|
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
if ($scope->setsFlags()) {
|
||||||
FollowUp::run(
|
FollowUp::run(
|
||||||
static fn () => DiviFlags::set($postId),
|
static fn () => DiviFlags::set($postId),
|
||||||
'divi builder flags',
|
'divi builder flags',
|
||||||
@@ -97,10 +102,19 @@ class DiviSaver
|
|||||||
. 'einmal im Divi-Builder öffnen und speichern.',
|
. 'einmal im Divi-Builder öffnen und speichern.',
|
||||||
$warnings
|
$warnings
|
||||||
);
|
);
|
||||||
|
}
|
||||||
FollowUp::run(
|
FollowUp::run(
|
||||||
static function () use ($postId): void {
|
static function () use ($postId, $scope): void {
|
||||||
if (class_exists('ET_Core_PageResource')) {
|
if (! class_exists('ET_Core_PageResource')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
|
\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',
|
'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
|
* The first content of a brand-new post: a draft of lit/content-create with divi or a library
|
||||||
* draft with its normal WordPress fields – title and excerpt went through kses as usual).
|
* entry of lit/library-save, both inserted with their normal WordPress fields first (title and
|
||||||
* Same kses window as save(), holding only this wp_update_post() and its hooks; no restore
|
* excerpt went through kses as usual). Same kses window as save(), holding only this
|
||||||
* point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part
|
* wp_update_post() and its hooks; no restore point (the post has no earlier state) and no
|
||||||
* of the write here: without them the new entry is no Divi layout. Every failure throws –
|
* static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them
|
||||||
* the caller deletes the draft (all or nothing).
|
* 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<string> Warnings (CHANGED_ON_SAVE).
|
* @return list<string> Warnings (CHANGED_ON_SAVE).
|
||||||
* @throws RuntimeException
|
* @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));
|
$refusal = self::refusal($this->writeContent($postId, $content));
|
||||||
if ($refusal !== null) {
|
if ($refusal !== null) {
|
||||||
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
|
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($scope->setsFlags()) {
|
||||||
DiviFlags::set($postId);
|
DiviFlags::set($postId);
|
||||||
|
}
|
||||||
|
|
||||||
$stored = get_post($postId);
|
$stored = get_post($postId);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ final class DiviSchema
|
|||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
|
'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.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ defined('ABSPATH') || exit;
|
|||||||
* The frame of the Divi write tools (spec §5.6): permission and execute both load the page
|
* 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
|
* 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
|
* 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
|
* validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
|
||||||
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
|
* GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
|
||||||
*/
|
*/
|
||||||
abstract class DiviWriteAbility implements Ability
|
abstract class DiviWriteAbility implements Ability
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,15 @@ abstract class DiviWriteAbility implements Ability
|
|||||||
|
|
||||||
public function outputSchema(): ?array
|
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
|
public function isDirect(): bool
|
||||||
@@ -43,6 +51,11 @@ abstract class DiviWriteAbility implements Ability
|
|||||||
|
|
||||||
final public function permission(array $input): bool|WP_Error
|
final public function permission(array $input): bool|WP_Error
|
||||||
{
|
{
|
||||||
|
$refusal = $this->precheck($input);
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
$page = $this->writer->page($input);
|
$page = $this->writer->page($input);
|
||||||
|
|
||||||
return $page instanceof WP_Error ? $page : true;
|
return $page instanceof WP_Error ? $page : true;
|
||||||
@@ -50,6 +63,11 @@ abstract class DiviWriteAbility implements Ability
|
|||||||
|
|
||||||
final public function execute(array $input): mixed
|
final public function execute(array $input): mixed
|
||||||
{
|
{
|
||||||
|
$refusal = $this->precheck($input);
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
$page = $this->writer->page($input);
|
$page = $this->writer->page($input);
|
||||||
if ($page instanceof WP_Error) {
|
if ($page instanceof WP_Error) {
|
||||||
return $page;
|
return $page;
|
||||||
@@ -57,9 +75,20 @@ abstract class DiviWriteAbility implements Ability
|
|||||||
|
|
||||||
$change = $this->change($page, $input, $this->writer->composition($page));
|
$change = $this->change($page, $input, $this->writer->composition($page));
|
||||||
if ($change instanceof WP_Error) {
|
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<string, mixed> $input
|
||||||
|
*/
|
||||||
|
protected function precheck(array $input): ?WP_Error
|
||||||
|
{
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||||
use WP_Error;
|
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
|
* (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,
|
* 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,
|
* 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
|
final class DiviWriter
|
||||||
{
|
{
|
||||||
@@ -35,7 +37,8 @@ final class DiviWriter
|
|||||||
private DiviSaver $saver,
|
private DiviSaver $saver,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private DesignSystemReader $design,
|
private DesignSystemReader $design,
|
||||||
private DiviComposition $composition
|
private DiviComposition $composition,
|
||||||
|
private ?GlobalEdits $globals = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +82,13 @@ final class DiviWriter
|
|||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
* @throws WriteFailed
|
* @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;
|
$post = $page->post;
|
||||||
$content = Serializer::document($page->document);
|
$content = Serializer::document($page->document);
|
||||||
if ($content === (string) $post->post_content) {
|
if ($content === (string) $post->post_content) {
|
||||||
@@ -97,7 +105,7 @@ final class DiviWriter
|
|||||||
$warnings = array_merge($change->warnings(), $quality);
|
$warnings = array_merge($change->warnings(), $quality);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$saved = $this->saver->save($post, $content);
|
$saved = $this->saver->save($post, $content, $scope);
|
||||||
} catch (WriteFailed $e) {
|
} catch (WriteFailed $e) {
|
||||||
if (! $e->rolledBack) {
|
if (! $e->rolledBack) {
|
||||||
$this->log->recordPartial(
|
$this->log->recordPartial(
|
||||||
@@ -140,6 +148,82 @@ final class DiviWriter
|
|||||||
return $tool->toArray();
|
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<string, mixed> $input
|
||||||
|
* @return array<string, mixed>|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
|
* 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)
|
* node has no block any more, but its ID from before the removal still names the (surviving)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
class DynamicContentMap
|
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.
|
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
|
||||||
@@ -42,6 +43,51 @@ class DynamicContentMap
|
|||||||
|
|
||||||
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
|
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
|
||||||
public function forPost(int $postId): ?array
|
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_<key> 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<string, array{label: string, group: string, settings: array<string, string>}>|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<array-key, mixed>|null
|
||||||
|
*/
|
||||||
|
private function raw(int $postId): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
|
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
|
||||||
@@ -51,7 +97,7 @@ class DynamicContentMap
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return is_array($options) && $options !== [] ? self::fromOptions($options) : null;
|
return is_array($options) && $options !== [] ? $options : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
|
||||||
|
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
|
||||||
|
|
||||||
|
/** What DiviSaver writes to – it decides the Divi flags and how much static CSS is cleared. */
|
||||||
|
enum SaveScope
|
||||||
|
{
|
||||||
|
/** A page or post: Divi flags, its own static CSS. */
|
||||||
|
case Page;
|
||||||
|
|
||||||
|
/** A library entry: no Divi flags (Divi sets none there, fixture finding 4), its own CSS. */
|
||||||
|
case LibraryItem;
|
||||||
|
|
||||||
|
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
|
||||||
|
case GlobalItem;
|
||||||
|
|
||||||
|
public function setsFlags(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearsSiteCss(): bool
|
||||||
|
{
|
||||||
|
return $this === self::GlobalItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {{link:<ID>}} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ defined('ABSPATH') || exit;
|
|||||||
* returned like any invalid input; partial write if the delete fails.
|
* returned like any invalid input; partial write if the delete fails.
|
||||||
* - discard(): writing after the insert failed – created terms and the draft are deleted;
|
* - discard(): writing after the insert failed – created terms and the draft are deleted;
|
||||||
* rolled back, or partial write if that cleanup failed.
|
* 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
|
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_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
||||||
public const REASON_LAYOUT = 'Das Divi-Layout konnte 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(
|
public function __construct(
|
||||||
private PostWriter $writer,
|
private ?PostWriter $writer,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private string $ability,
|
private string $ability,
|
||||||
private string $postType
|
private string $postType,
|
||||||
|
private string $noun = 'Entwurf'
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ final class NewDraft
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
// 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).
|
// 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);
|
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
||||||
}
|
}
|
||||||
if ($postId instanceof WP_Error) {
|
if ($postId instanceof WP_Error) {
|
||||||
@@ -77,7 +80,7 @@ final class NewDraft
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $deleted) {
|
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);
|
$this->logPartial((string) $postId, $summary);
|
||||||
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
||||||
throw WriteFailed::partial(
|
throw WriteFailed::partial(
|
||||||
@@ -102,7 +105,7 @@ final class NewDraft
|
|||||||
public function discard(int $postId, Throwable $cause, string $reason): never
|
public function discard(int $postId, Throwable $cause, string $reason): never
|
||||||
{
|
{
|
||||||
$problems = Rollback::attempt(
|
$problems = Rollback::attempt(
|
||||||
fn () => $this->writer->deleteCreatedTerms(),
|
fn () => $this->writer?->deleteCreatedTerms(),
|
||||||
static fn () => self::deleteDraft($postId)
|
static fn () => self::deleteDraft($postId)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ final class NewDraft
|
|||||||
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
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);
|
$this->logPartial((string) $postId, $summary);
|
||||||
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ final class PostGuard
|
|||||||
|
|
||||||
$post = get_post($id);
|
$post = get_post($id);
|
||||||
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
|
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)) {
|
if (! current_user_can('edit_post', $id)) {
|
||||||
@@ -63,6 +63,12 @@ final class PostGuard
|
|||||||
return $post;
|
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
|
* 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
|
* less than a minute away: wp_insert_post() turns "future" into "publish" on any save when
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class PageRenderSummaryAbility implements Ability
|
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)
|
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 '
|
. '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 '
|
. 'ü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. '
|
. '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
|
public function inputSchema(): array
|
||||||
@@ -104,10 +108,32 @@ final class PageRenderSummaryAbility implements Ability
|
|||||||
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
|
$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(
|
return array_merge(
|
||||||
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
|
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
|
||||||
RenderSummary::fromHtml($this->renderer->render($post)),
|
RenderSummary::fromHtml($this->renderer->render($post)),
|
||||||
['notes' => $notes]
|
['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<string>
|
||||||
|
*/
|
||||||
|
private static function globalModuleIds(string $content): array
|
||||||
|
{
|
||||||
|
preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches);
|
||||||
|
|
||||||
|
return array_values(array_unique($matches[1]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-3
@@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
|||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||||
@@ -43,7 +44,7 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class Plugin
|
final class Plugin
|
||||||
{
|
{
|
||||||
public const VERSION = '0.3.0';
|
public const VERSION = '0.4.0';
|
||||||
|
|
||||||
private static string $pluginFile = '';
|
private static string $pluginFile = '';
|
||||||
private static ?SettingsPage $settingsPage = null;
|
private static ?SettingsPage $settingsPage = null;
|
||||||
@@ -71,9 +72,12 @@ final class Plugin
|
|||||||
(new ChangeLogCleanup($changeLog, $limits))->register();
|
(new ChangeLogCleanup($changeLog, $limits))->register();
|
||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$divi = new DiviIntegration($schema, $changeLog);
|
|
||||||
$registry = null;
|
$registry = null;
|
||||||
$policy = 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,
|
// 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.
|
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
|
||||||
$diviWritable = static function () use (&$registry, &$policy): bool {
|
$diviWritable = static function () use (&$registry, &$policy): bool {
|
||||||
@@ -81,11 +85,30 @@ final class Plugin
|
|||||||
&& $policy instanceof ExposurePolicy
|
&& $policy instanceof ExposurePolicy
|
||||||
&& $policy->allows('lit/divi-insert', $registry->abilities());
|
&& $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(
|
$registry = new IntegrationRegistry(
|
||||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||||
new MediaIntegration($schema, $changeLog, $limits),
|
new MediaIntegration($schema, $changeLog, $limits),
|
||||||
$divi
|
$divi,
|
||||||
|
$divi->library()
|
||||||
);
|
);
|
||||||
|
|
||||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||||
|
|||||||
@@ -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 `<rolle>.json` (Post-Zeile, **alle** Meta-Einträge roh in Speicherreihenfolge, alle Term-Beziehungen direkt aus den Tabellen) und, falls Inhalt vorhanden, `<rolle>.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:<Modul-ID>})` (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(<Modul>, <Library-ID>)`, `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:<uuid>, 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** (`<!-- wp:divi/text {…} /-->`, 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:** `<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/section","localAttrs":{…alle Attribute des Moduls…}} -->`, 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section -->
|
||||||
|
|
||||||
|
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Globales Modul Öffnungszeiten"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/button {"module":{"meta":{"adminLabel":{"desktop":{"value":"Lokaler Button"}}}},"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt aufnehmen","linkUrl":"#kontakt"}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section -->
|
||||||
|
|
||||||
|
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Globales Modul Öffnungszeiten"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/placeholder -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/section","localAttrs":{"builderVersion":"5.2.1","module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}}}} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1","globalParent":"1058"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1","globalParent":"1058"} -->
|
||||||
|
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1","globalParent":"1058"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1","globalParent":"1058"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/global-layout -->
|
||||||
|
|
||||||
|
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/text","localAttrs":{"builderVersion":"5.2.1","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Globales Modul Öffnungszeiten"}}}}}} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/button {"module":{"meta":{"adminLabel":{"desktop":{"value":"Lokaler Button"}}}},"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt aufnehmen","linkUrl":"#kontakt"}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section -->
|
||||||
|
|
||||||
|
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Body mit Dynamic Content Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Body mit Dynamic Content Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Titel dynamisch"}}}},"title":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_title\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022}}})$"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h1"}}}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Meta-Feld dynamisch"}}}},"content":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_meta_key\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022,\u0022select_meta_key\u0022:\u0022custom_meta_manual_custom_field_value\u0022,\u0022date_format\u0022:\u0022default\u0022,\u0022custom_date_format\u0022:\u0022\u0022,\u0022enable_html\u0022:\u0022off\u0022,\u0022meta_key\u0022:\u0022fixture_untertitel\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Auszug dynamisch"}}}},"content":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_excerpt\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022,\u0022words\u0022:\u0022\u0022,\u0022read_more_label\u0022:\u0022\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/post-content {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Beitragsinhalt"}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Footer Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Footer Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Footer Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eFixture-Footer – Tischlerei Holzwerk Berger\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Globaler Header Abschnitt"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Globaler Header Zeile"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Header Titel"}}}},"title":{"innerContent":{"desktop":{"value":"Tischlerei – Globaler Header (Fixture)"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
|
||||||
|
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Header Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobaler Header der Standard-Vorlage.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
|
||||||
|
<!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/row -->
|
||||||
|
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Artikel"}}},"advanced":{"html":{"elementType":{"desktop":{"value":"article"}}}},"decoration":{"attributes":{"desktop":{"value":{"attributes":[{"id":"lit-attr-1","name":"data-track","value":"hero","targetElement":""}]}}}}},"content":{"innerContent":{"desktop":{"value":"<p>Text mit eigenem Element und Attribut.</p>"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Eigene Felder bleiben"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Überschrift"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":"/kontakt"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Artikel"}}},"advanced":{"html":{"elementType":{"desktop":{"value":"article"}}}},"decoration":{"attributes":{"desktop":{"value":{"attributes":[{"id":"lit-attr-1","name":"data-track","value":"hero","targetElement":""}]}}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eText mit eigenem Element und Attribut.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Eigene Felder bleiben"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Überschrift"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":"/kontakt"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the stage 3 Divi fixtures (library items, global instances, Theme Builder) from the
|
||||||
|
* local site into tests/Fixtures/divi/stage3/: one <role>.json per post (post row, all meta,
|
||||||
|
* all term relationships), the raw post_content byte-exact as <role>.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));
|
||||||
Executable
+473
@@ -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 <run>", 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("<!-- wp:divi/placeholder -->"), -strlen("<!-- /wp:divi/placeholder -->"));
|
||||||
|
$page = $read("page--globale-instanzen");
|
||||||
|
$from = strpos($page, "<!-- wp:divi/global-layout {\"globalModule\":\"1059\"");
|
||||||
|
$instance = substr($page, $from, strpos($page, " /-->", $from) + 5 - $from);
|
||||||
|
$globalSection = substr($page, strlen("<!-- wp:divi/placeholder -->"),
|
||||||
|
strpos($page, "<!-- /wp:divi/global-layout -->\n\n") + strlen("<!-- /wp:divi/global-layout -->\n\n") - strlen("<!-- wp:divi/placeholder -->"));
|
||||||
|
$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, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Library Abschnitt\""))) . "<!-- /wp:divi/placeholder -->"; 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 "<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column {\"module\":{\"decoration\":{\"sizing\":{\"desktop\":{\"value\":{\"flexType\":\"24_24\"}}}}}} -->$EXPECTED_INSTANCE<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->" "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":"<p>LIT Smoke geändert</p>"}}]'
|
||||||
|
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:<ID>}}")) | 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("<p>Probe</p>")];
|
||||||
|
$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" => "<b data-probe=\"1\">Probe</b>"])]]])),
|
||||||
|
];
|
||||||
|
$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":"<p>LIT Smoke Redakteur</p>"}}]'
|
||||||
|
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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! class_exists('ET_Core_PageResource')) {
|
||||||
|
/**
|
||||||
|
* Divi's static CSS cache for the saver tests: records every remove_static_resources() call.
|
||||||
|
*/
|
||||||
|
class ET_Core_PageResource
|
||||||
|
{
|
||||||
|
/** @var list<array{0: int|string, 1: string, 2: bool}> */
|
||||||
|
public static array $removed = [];
|
||||||
|
|
||||||
|
public static function remove_static_resources($postId, $owner, $force = false): void
|
||||||
|
{
|
||||||
|
self::$removed[] = [$postId, $owner, (bool) $force];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user