Merge etappe-3: Divi-Bibliothek und Theme Builder (0.5.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
+45
-20
@@ -17,7 +17,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
|
||||
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
|
||||
können trotzdem unerkannt bleiben; dazu gehört ein maskiert in einem Modulwert gespeicherter
|
||||
`divi/global-layout`-Block: Divi wertet Blöcke in der Ausgabe von Shortcode-Modulen und in
|
||||
WooCommerce-Textmodulen (Warenkorb, Kasse) erneut aus, sodass er dort als globales Modul erscheinen kann
|
||||
(bekanntes Restrisiko, wie die übrigen ungewöhnlichen Schreibweisen; es braucht Bearbeitungsrechte auf der
|
||||
verweisenden Seite). 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.
|
||||
@@ -29,8 +33,26 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
|
||||
- `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.
|
||||
ersetzen (siehe Ablauf unten). Ein Knoten, der selbst ein globales Modul ist oder auf einen Eintrag verweist
|
||||
(auch als Divi-4-Shortcode mit `global_module` in einem Text), lässt sich nicht als globaler Eintrag speichern.
|
||||
- `lit/library-find-similar`: Knoten finden, die wie ein Knoten (`post_id` und `id`) oder ein Eintrag
|
||||
(`library_id`) aufgebaut sind. `match: "identical"` = gleicher Inhalt einschließlich aller Texte (nur
|
||||
Admin-Labels ausgenommen), `"structure"` = gleicher Aufbau (Modultypen, Spalten, Presets) mit anderen Inhalten.
|
||||
Durchsucht werden Einträge der Divi-Inhaltstypen (optional nur `post_type`), nie Bibliothekseinträge oder
|
||||
Theme-Builder-Layouts; nur Einträge, die das Konto bearbeiten darf. Höchstens 200 Einträge je Aufruf
|
||||
(`limit`), weiter mit `next_cursor`; bei sehr vielen Treffern endet ein Aufruf früher und sagt das in `note`.
|
||||
Jeder Treffer nennt `tree_rev` und `replaceable` (`true`: inhaltsgleich und in einer Seite oder einem Beitrag –
|
||||
nur solche Treffer ersetzt `lit/library-make-global`).
|
||||
- `lit/library-make-global`: einen Knoten einer Seite oder eines Beitrags in ein globales Modul umwandeln
|
||||
(`post_id`, `tree_rev`, `id`, `title`, optional `categories`/`tags`): neuer globaler Eintrag mit genau diesem
|
||||
Inhalt, der Knoten wird durch die Instanz ersetzt – alles oder nichts. Weitere, inhaltsgleiche Vorkommen
|
||||
ersetzen: `library_id` und `targets` (je `post_id`, `tree_rev`, `ids` der Treffer `"identical"`; höchstens 20
|
||||
Seiten). Alle Ziele werden vorher geprüft; ohne `confirm` ist das ein Probelauf (`confirm_required` mit
|
||||
`affected`), dann `confirm: true`. Jede Seite bekommt eine Revision (`pages`). Nur Seiten und Beiträge
|
||||
(Inhaltstypen `page` und `post`), die das Konto bearbeiten darf – keine Bibliothekseinträge und keine
|
||||
Theme-Builder-Layouts, auch nicht als Quelle. Knoten, die auf einen Eintrag verweisen, werden kein globales
|
||||
Modul. Schlägt eine Seite fehl, bleiben die vorher ersetzten Seiten ersetzt (je mit Revision), die übrigen
|
||||
unverändert; der Fehler nennt alle drei Gruppen (`changed_posts`, `failed_post`, `unchanged_posts`).
|
||||
- `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
|
||||
@@ -54,15 +76,17 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
|
||||
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
|
||||
Sicherungskopie im Papierkorb (`backup_id`). `lit/revision-restore` gilt nicht für Bibliothekseinträge:
|
||||
Zurück geht es über `restore_url` in der Antwort (Revisionsvergleich in WordPress, dort wiederherstellen). 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
|
||||
Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn das Konto den Theme Builder
|
||||
nicht benutzen darf (Divi-Rolle „Theme Builder“, `edit_others_posts`, `edit_theme_options`) oder die Gruppe
|
||||
„Divi Theme Builder“ auf „nur lesen“ steht; ohne Zugang zum Theme Builder erscheinen sie nur als Anzahl
|
||||
(`hidden_count`, `blocked_hidden_count`), nie mit ID oder Titel. 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).
|
||||
@@ -80,6 +104,9 @@ wird leer; ein globaler Eintrag enthält keine globalen Module. Nur aktive Eintr
|
||||
`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.
|
||||
|
||||
Fügt eine Divi-Änderung einen Abschnitt ein, der wie ein Abschnitt der Bibliothek aufgebaut ist, nennt
|
||||
`warnings` den Eintrag – dann `lit-library-insert` erwägen.
|
||||
|
||||
## Rechte
|
||||
|
||||
- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen
|
||||
@@ -92,10 +119,8 @@ sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.
|
||||
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.
|
||||
Ohne das Recht „Divi-Bibliothek“ oder mit deaktivierter Gruppe nennen `lit-divi-outline`, `lit-divi-node-get`
|
||||
und `lit-page-render-summary` globale Module nur als solche, ohne ihre ID.
|
||||
`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`,
|
||||
@@ -109,13 +134,13 @@ sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.
|
||||
|
||||
## 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.
|
||||
1. Wiederkehrende Abschnitte suchen: `lit/library-find-similar` mit einem typischen Abschnitt (`post_id`, `id`)
|
||||
– z. B. Kontakt, Öffnungszeiten, Call-to-Action.
|
||||
2. Dem Nutzer die Treffer zeigen und erklären: eine Änderung wirkt danach auf allen Seiten mit dem Modul.
|
||||
Treffer mit `match: "structure"` unterscheiden sich im Inhalt – vorher klären, welche Fassung gilt, und die
|
||||
übrigen angleichen (`lit-divi-update`) oder weglassen.
|
||||
3. Nach Zustimmung `lit/library-make-global` mit dem gewählten Abschnitt.
|
||||
4. Die Treffer mit `replaceable: true` mit `lit/library-make-global` (`library_id`, `targets` mit der `tree_rev`
|
||||
aus `lit/library-find-similar`) ersetzen: Probelauf zeigen, dann `confirm: true`. Inhaltsgleiche Treffer in
|
||||
anderen Inhaltstypen (`replaceable: false`) im Divi-Builder durch das globale Modul ersetzen.
|
||||
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.
|
||||
|
||||
@@ -33,12 +33,16 @@ beginnt (geschützte Meta-Daten), sind nicht erlaubt.
|
||||
|
||||
In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:<ID>}}` verwenden statt fester
|
||||
Adressen wie `/?page_id=…`. Der Eintrag muss existieren und für das Konto lesbar sein; Einträge im Papierkorb,
|
||||
automatische Entwürfe und Medien (Anhänge – dafür die Adresse aus der Mediathek) lehnt das Werkzeug ab,
|
||||
ebenso `{{link:…}}` außerhalb von Link-Feldern. Solange das Ziel ein Entwurf oder privat ist, zeigt Divi
|
||||
automatische Entwürfe, Einträge der Divi-Bibliothek und Medien (Anhänge – dafür die Adresse aus der Mediathek)
|
||||
lehnt das Werkzeug ab – wie eine unbekannte ID –, 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).
|
||||
|
||||
Dieselben Regeln gelten für die Vollform `post_link_url_<Typ>` mit `post_id`: das Ziel muss lesbar sein, darf
|
||||
nicht im Papierkorb liegen, kein Anhang und kein Bibliothekseintrag sein und muss genau den Typ im Namen haben
|
||||
(`post_link_url_page` nur für Seiten). `{{link:<ID>}}` wählt den passenden Namen selbst.
|
||||
|
||||
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
|
||||
|
||||
+13
-7
@@ -23,9 +23,9 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
|
||||
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
||||
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`.
|
||||
|
||||
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter
|
||||
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
||||
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
||||
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek,
|
||||
Divi Theme Builder). Jede Gruppe hat unter Einstellungen → LIT MCP → Tools eine eigene Einstellung
|
||||
(direkt/indirekt/deaktiviert), die ihre Tools übernehmen („wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
||||
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
|
||||
direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten
|
||||
Tools warnt die Seite (Kontextkosten im Assistenten). Änderungen gelten ab der nächsten Verbindung.
|
||||
@@ -37,13 +37,17 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
|
||||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
||||
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`,
|
||||
`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
|
||||
`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`,
|
||||
`lit/library-find-similar`, `lit/library-make-global`), Divi Theme Builder (`lit/tb-list`,
|
||||
`lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate`).
|
||||
|
||||
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`.
|
||||
der Divi-Bibliothek und auf Layouts des Theme Builders (ID als `post_id`); globale Module: `lit-guide` topic
|
||||
`divi-library`, Vorlagen: `lit-guide` topic `theme-builder`.
|
||||
|
||||
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
|
||||
`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`.
|
||||
`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `divi-library` oder
|
||||
`theme-builder`.
|
||||
|
||||
Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf.
|
||||
|
||||
@@ -52,7 +56,9 @@ Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in ein
|
||||
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
|
||||
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand
|
||||
des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt);
|
||||
`lit/divi-replace-text` nennt `tree_rev` je Seite in `results` (Probelauf: in `matches`).
|
||||
`lit/divi-replace-text` nennt `tree_rev` je Seite in `results` (Probelauf: in `matches`). Bei
|
||||
Bibliothekseinträgen und Theme-Builder-Layouts kommt `restore_url` hinzu (Revisionsvergleich in WordPress) –
|
||||
`lit/revision-restore` gilt nur für Inhaltstypen.
|
||||
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
||||
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
||||
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Divi Theme Builder
|
||||
|
||||
Der Theme Builder legt fest, wie Kopfzeile, Inhalt und Fußzeile für bestimmte Seiten aussehen – z. B. für alle
|
||||
Einträge eines eigenen Inhaltstyps. Eine **Vorlage** hat Bedingungen („gilt für alle Personen“) und bis zu drei
|
||||
**Layouts** (Kopfzeile, Inhalt, Fußzeile). Die **Standardvorlage** gilt überall, wo keine andere greift; ihre
|
||||
Kopf- und Fußzeile heißen „global“, weil andere Vorlagen sie mitbenutzen.
|
||||
|
||||
## Grundregel: nichts wird ohne Freigabe sichtbar
|
||||
|
||||
- Neue Vorlagen legt LIT MCP immer **deaktiviert** an – Besucher sehen nichts davon.
|
||||
- Aktivieren und Deaktivieren gibt es nur mit `lit/tb-activate`, und immer so: Probelauf (Bedingungen, Layouts,
|
||||
andere aktive Vorlagen mit denselben oder verwandten Bedingungen unter `overlaps`/`possible_overlaps`) →
|
||||
Nutzer fragen → dieselbe Anfrage mit `confirm: true`.
|
||||
- Auch alles andere, was Besucher sofort sähen, ist zuerst ein Probelauf (`confirm_required`) und braucht
|
||||
`confirm: true`: Bedingungen einer aktiven Vorlage ändern (`lit/tb-assign`) und ein Layout ändern, das eine
|
||||
aktive Vorlage zeigt (die `lit-divi-…`-Werkzeuge; die Antwort nennt die Vorlagen unter `templates`, solche
|
||||
ohne Berechtigung nur als Anzahl `hidden_templates`). Vor dem
|
||||
Speichern eines sichtbaren Layouts entsteht eine Revision (wiederherstellbar über `restore_url`, siehe
|
||||
„Rückgängig machen“); sind Revisionen abgeschaltet, eine Sicherungskopie im Papierkorb (`backup_id`).
|
||||
- `lit/divi-replace-text` ändert sichtbare Layouts nicht; solche Änderungen mit `lit-divi-update` und
|
||||
`confirm: true` vornehmen.
|
||||
- Die Standardvorlage ändert LIT MCP nicht (nur im Theme Builder).
|
||||
|
||||
## Werkzeuge (über `mcp-adapter-execute-ability`)
|
||||
|
||||
- `lit/tb-list`: alle Vorlagen mit `enabled`, `live` (für Besucher wirksam), `status`, Bedingungen (`use_on`,
|
||||
`exclude_from` und Beschriftungen), Layouts (`id`, Titel, `shared_with` = andere Vorlagen mit demselben
|
||||
Layout) und `template_rev`. `template_id`: nur diese Vorlage. `conditions: true` liefert alle Bedingungen,
|
||||
die Divi anbietet; eine ID, die auf „:“ endet, braucht ein Objekt am Ende, z. B.
|
||||
`singular:post_type:page:id:42`. `detached` zeigt Vorlagen, die ein Speichern im Theme Builder entfernt hat
|
||||
(siehe unten, höchstens 50 je Aufruf); `draft_pending` zeigt ungespeicherte Änderungen im Theme Builder. Eine
|
||||
aktive Vorlage, die nicht veröffentlicht ist (z. B. im Papierkorb), lässt sich nur deaktivieren.
|
||||
- `lit/tb-create-template`: `title`, je Bereich (`header`, `body`, `footer`) `source`:
|
||||
`"new"` (neues Layout; optional `nodes` wie bei `lit-divi-insert` und `title`), `"global"` (Kopf-/Fußzeile
|
||||
der Standardvorlage mitbenutzen; nicht für `body`), `"default"` (nichts Eigenes, Standard bei fehlender
|
||||
Angabe) oder `"hidden"` (Bereich ausblenden). Optional `use_on`/`exclude_from`. Alles oder nichts: schlägt
|
||||
ein Schritt fehl, entfernt LIT MCP alles wieder. Antwort: `template_id`, `template_rev`, `enabled: false`,
|
||||
`layouts`.
|
||||
- `lit/tb-assign`: Bedingungen ersetzen (`template_id`, `template_rev`, `use_on`). Fehlt `exclude_from`,
|
||||
bleiben die bisherigen Ausnahmen; `[]` entfernt sie. Der Probelauf einer aktiven Vorlage zeigt vorher/nachher
|
||||
(`before`, `after`) und Überschneidungen. Die Antwort nennt die bisherigen Bedingungen unter `previous`.
|
||||
- `lit/tb-activate`: `enabled: true` oder `false` (`template_id`, `template_rev`). Deaktivieren ist der
|
||||
schnelle Weg zurück. Zum Aktivieren müssen alle Layouts der Vorlage noch aktiv sein.
|
||||
- Layouts bearbeiten: `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-insert`, `lit-divi-update`,
|
||||
`lit-divi-move`, `lit-divi-delete` … mit der Layout-ID als `post_id` (aus `lit/tb-list`). Benutzt eine
|
||||
andere Vorlage dasselbe Layout (`shared_with`), wirkt die Änderung auch dort. Das Modul „Beitragsinhalt“
|
||||
(`post-content`) gibt es nur im Inhalt-Layout, nicht in Kopf- oder Fußzeile.
|
||||
|
||||
## Bedingungen
|
||||
|
||||
Erlaubt sind nur die Bedingungen, die Divi selbst anbietet (`lit/tb-list` mit `conditions: true`), höchstens 50
|
||||
je Liste. Ein Objekt am Ende (Seite, Beitrag, Begriff, Person) muss existieren und für das Konto lesbar bzw.
|
||||
zuweisbar sein; sonst lautet die Antwort wie bei einer unbekannten ID. Bedingungen für Autoren
|
||||
(`archive:user:id:…`) verlangen das Recht, Benutzer aufzulisten (`list_users`) – strenger als Divi. Bedingungs-IDs
|
||||
bestehen nur aus Buchstaben, Ziffern, `_`, `:` und `-`; eine Rolle, deren Kennung andere Zeichen enthält (manche
|
||||
Rollen-Plugins), dann im Divi Theme Builder setzen.
|
||||
|
||||
## Felder im Inhalt-Layout (Dynamic Content)
|
||||
|
||||
Im Inhalt-Layout zeigen Platzhalter die Daten des jeweiligen Eintrags: `{{post:title}}`, `{{post:excerpt}}`,
|
||||
`{{post:date}}` und Felder als `{{field:<Schlüssel>}}`. Welche Felder ein Inhaltstyp hat und welche Divi als Text
|
||||
zeigt, liefert `lit/divi-dynamic-sources` mit `post_type`. Nur textartige Felder eignen sich: Bei Auswahlfeldern
|
||||
zeigt Divi den gespeicherten Wert, nicht die Beschriftung; Beziehungs- und Dateifelder zeigen nur IDs, Pods mit
|
||||
Tabellen-Speicherung gar nichts.
|
||||
|
||||
LIT MCP prüft die Felder eines geänderten Inhalt-Layouts gegen die Inhaltstypen aus den Bedingungen
|
||||
(`singular:post_type:<typ>:…`) der Vorlagen, die das Layout benutzen, und warnt (`warnings`) – gespeichert wird
|
||||
trotzdem. Das gilt für die Kurzform `{{field:…}}` und für Felder, die im Divi-Builder über „Benutzerdefiniertes
|
||||
Feld“ gewählt wurden. Bei einem sichtbaren Layout stehen die Warnungen schon im Probelauf. Ohne solche Bedingung
|
||||
bleiben die Felder ungeprüft (die Antwort sagt das). Kopf- und Fußzeile werden nicht geprüft.
|
||||
|
||||
## Wenn der Theme Builder gerade offen ist
|
||||
|
||||
- Hat der Theme Builder ungespeicherte Änderungen (Entwurf, `draft_pending`), lehnen `lit/tb-create-template`,
|
||||
`lit/tb-assign` und das Aktivieren ab: erst dort speichern oder verwerfen.
|
||||
- Deaktivieren (`lit/tb-activate` mit `enabled: false`) geht immer – auch dann, und auch bei einer aktiven
|
||||
Vorlage im Papierkorb. Die Antwort warnt: Speichert jemand ein offenes Theme-Builder-Fenster, kann Divi
|
||||
seinen eigenen Stand wiederherstellen; danach mit `lit/tb-list` prüfen.
|
||||
- Speichert jemand in einem Theme-Builder-Fenster, das vor dem Anlegen einer Vorlage geöffnet wurde, entfernt
|
||||
Divi die neue Vorlage wieder aus der Liste und löscht sie 7 Tage später. `lit/tb-list` zeigt sie dann unter
|
||||
`detached` (mit `marked_unused`). Nur auf ausdrücklichen Wunsch: `lit/tb-assign` mit `reattach: true` ordnet
|
||||
sie wieder zu – immer deaktiviert, nur Vorlagen unter `detached` (keine, die eine Theme-Builder-Vorlage der
|
||||
Divi-Bibliothek nutzt) und nur, wenn ihre Layouts noch aktiv sind.
|
||||
|
||||
## Rechte
|
||||
|
||||
- WordPress: „Beiträge anderer bearbeiten“ (`edit_others_posts`) und „Design bearbeiten“
|
||||
(`edit_theme_options`); je Vorlage und Layout das Recht, sie zu bearbeiten; zum Anlegen zusätzlich
|
||||
„veröffentlichen“ (Divi legt Vorlagen und Layouts sofort aktiv an).
|
||||
- Divi → Rollen-Editor: „Theme Builder“ – standardmäßig nur für Administratoren.
|
||||
- Fehlt eines davon, gibt es für dieses Konto keine Vorlagen und Layouts (`not_found` wie bei einer unbekannten
|
||||
ID bzw. `forbidden`). Vorlagen, die das Konto nicht bearbeiten darf, erscheinen nirgends; aktive davon nur als
|
||||
Anzahl (`hidden_live` in `lit/tb-list`, `hidden_overlaps` im Probelauf, `hidden_templates` beim Ändern eines
|
||||
Layouts).
|
||||
- Einstellungen → LIT MCP → Tools: Gruppe „Divi Theme Builder“ auf „nur lesen“ sperrt jedes Schreiben, auch über
|
||||
die `lit-divi-…`-Werkzeuge und über globale Module in Layouts; deaktiviert gibt es keine Vorlagen und Layouts.
|
||||
Nur dieser Gruppenschalter hält die `lit-divi-…`-Werkzeuge von Theme-Builder-Layouts fern – einzelne
|
||||
`lit/tb-…`-Werkzeuge abzuschalten reicht dafür nicht.
|
||||
|
||||
## Globale Module im Theme Builder
|
||||
|
||||
`lit/library-make-global` wandelt keine Knoten in Theme-Builder-Layouts um. Stattdessen den Abschnitt mit
|
||||
`lit/library-save` (`scope: "global"`) speichern und mit `lit-library-insert` (`mode: "global"`) einfügen
|
||||
(`lit-guide` topic `divi-library`).
|
||||
|
||||
## Rückgängig machen
|
||||
|
||||
Vorlagen haben keine Revisionen: `lit/tb-assign` nennt die bisherigen Bedingungen (`previous`), `lit/tb-activate`
|
||||
schaltet zurück; jede Änderung steht im Protokoll. Layouts erhalten wie Divi-Seiten eine Revision (`revision_id`),
|
||||
aber `lit/revisions-list` und `lit/revision-restore` gelten nur für Seiten, Beiträge und andere Inhaltstypen,
|
||||
nicht für Theme-Builder-Layouts: Die Antwort nennt deshalb `restore_url`, den Revisionsvergleich in WordPress,
|
||||
in dem der Nutzer die Revision wiederherstellt. Danach die Seite im Divi-Builder bzw. auf der Website prüfen –
|
||||
Divi zwischenspeichert Layouts und CSS; zeigt sie noch den alten Stand, „Statische CSS-Dateien leeren“ (Divi →
|
||||
Theme-Optionen → Builder). Schneller Weg zurück bei einem sichtbaren Layout: die Vorlage mit
|
||||
`lit/tb-activate` (`enabled: false`) ausblenden. Neu angelegte Vorlagen im Divi Theme Builder löschen.
|
||||
|
||||
## Ablauf: Vorlage für einen Inhaltstyp (custom-post-type-vorlage)
|
||||
|
||||
1. Inhaltstyp und Felder klären: `lit-content-types`, dann `lit/divi-dynamic-sources` mit `post_type`.
|
||||
2. `lit/tb-list` lesen: gibt es schon eine Vorlage für den Typ? Gibt es eine globale Kopf-/Fußzeile?
|
||||
3. Mit dem Nutzer den Aufbau abstimmen (welche Felder, welche Reihenfolge, Kopf-/Fußzeile global oder eigen).
|
||||
4. `lit/tb-create-template` mit `use_on: ["singular:post_type:<typ>:all"]`, `header`/`footer` meist
|
||||
`{"source": "global"}`, `body` mit `source: "new"` und `nodes` (Überschrift `{{post:title}}`, Texte mit
|
||||
`{{field:…}}`, das Modul `post-content`, wenn der Eintrag eigenen Inhalt hat). Warnungen zu Feldern beachten.
|
||||
5. Das Inhalt-Layout bei Bedarf mit `lit-divi-outline`/`lit-divi-update` verfeinern (Layout-ID als `post_id`;
|
||||
solange die Vorlage deaktiviert ist, ohne Probelauf).
|
||||
6. Den Nutzer bitten, die Vorlage im Theme Builder anzusehen (Vorschau eines Eintrags ist erst nach dem
|
||||
Aktivieren möglich).
|
||||
7. Erst auf ausdrücklichen Wunsch: `lit/tb-activate` mit `enabled: true` – Probelauf zeigen, dann
|
||||
`confirm: true`.
|
||||
@@ -26,8 +26,9 @@
|
||||
|
||||
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`.
|
||||
Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Wiederkehrende Abschnitte finden und zu globalen
|
||||
Modulen machen: `lit/library-find-similar`, `lit/library-make-global`. Globale Module ändern und neue
|
||||
vorschlagen: `lit-guide` topic `divi-library`.
|
||||
|
||||
## Text auf vielen Seiten ersetzen
|
||||
|
||||
@@ -68,3 +69,9 @@ geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag änd
|
||||
1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele.
|
||||
2. `lit-content-create` mit allen Pflichtfeldern in `fields`.
|
||||
3. Beziehungen über IDs (vorher mit `lit-content-query` suchen).
|
||||
|
||||
## Vorlage für einen Inhaltstyp (Theme Builder)
|
||||
|
||||
Wie Einträge eines Inhaltstyps (z. B. Personen) auf der Website aussehen, legt eine Theme-Builder-Vorlage fest.
|
||||
Ablauf und Regeln: `lit-guide` topic `theme-builder`. Neue Vorlagen sind deaktiviert; aktiviert wird nur auf
|
||||
ausdrücklichen Wunsch mit `lit/tb-activate`.
|
||||
|
||||
@@ -220,3 +220,49 @@ Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantt
|
||||
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).
|
||||
|
||||
## 7. Umsetzung Etappe 3b (Plan 2026-09-27-etappe-3b-theme-builder.md)
|
||||
|
||||
Rulings Q1–Q25: `.superpowers/sdd/2026-09-27-etappe-3b-theme-builder/progress.md`.
|
||||
|
||||
- E7 präzisiert: Layouts und neue Vorlagen über `et_theme_builder_insert_layout`/`et_theme_builder_store_template`
|
||||
mit Rechteprüfung davor und `wp_die`-Sperre währenddessen; Zuordnung zur Live-Liste, Bedingungen und
|
||||
`_et_enabled` als Nachbildung von Divis Meta-Schreibweise (store_template würde bei bestehenden Vorlagen auch
|
||||
Standard-Kennzeichen, Titel und Onboarding-Meta neu schreiben). Nach dem Anlegen Rücklesen; Abweichung =
|
||||
alles zurücknehmen, auch Posts, die Divi vor einem Fehler schon eingefügt hatte (Q19).
|
||||
- E8/Antwort F1 umgesetzt als eigenes Tool `lit/tb-activate` (statt `enabled` in `lit/tb-assign`); neue Vorlagen
|
||||
sind immer deaktiviert. Jede andere Änderung, die Besucher sofort sähen (Bedingungen aktiver Vorlagen, Layouts
|
||||
aktiver Vorlagen), ist zuerst ein Probelauf und braucht `confirm`; Layouts mit der Sicherung aus
|
||||
`VisibleEdits` (Q2, Q9). Aktiv = zugeordnet und `_et_enabled`, unabhängig vom Post-Status (Q8).
|
||||
- Der Ausschalter (`lit/tb-activate` mit `enabled: false`) geht immer: auch bei TB-Entwurf, dann mit Warnung
|
||||
(Q20), und bei einer aktiven Vorlage im Papierkorb (Q16).
|
||||
- TB-Editor: Ablehnung bei TB-Entwurf; neue Vorlagen hängen sofort an der Live-Liste; `lit/tb-list` zeigt vom
|
||||
Theme Builder entfernte Vorlagen (`detached`), `lit/tb-assign` mit `reattach` ordnet sie deaktiviert wieder zu
|
||||
(nur mit aktiven Layouts, Q8/B10). Befunde 10–12: `tests/Fixtures/divi/stage3b/README.md`.
|
||||
- E3 für den Theme Builder: `edit_others_posts`, `edit_theme_options` (Q15) und Divis Rollen-Recht
|
||||
„Theme Builder“, je Vorlage/Layout `edit_post`, zum Anlegen die Veröffentlichen-Rechte. Nicht bearbeitbare
|
||||
Vorlagen antworten wie fehlende (Q18); aktive davon nur als Anzahl in Liste und Probeläufen
|
||||
von `lit/tb-assign`/`lit/tb-activate` (Q21) und der Layout-Änderungen (`hidden_templates`; die Rückfrage
|
||||
gilt trotzdem für alle aktiven Vorlagen, Q22). Globale Bibliotheksänderungen: Theme-Builder-Layouts in der
|
||||
Verwendungsliste nur mit Theme-Builder-Zugang, sonst nur gezählt; bestätigen nur mit den Theme-Builder-Rechten
|
||||
und schreibbarer Gruppe (Q17 M1). Revisionen von Layouts und Bibliothekseinträgen: `restore_url` zum
|
||||
WordPress-Revisionsvergleich, `lit/revision-restore` bleibt bei den Inhaltstypen (Audit L3, Etappe U).
|
||||
- Bedingungen nur aus Divis Katalog; Objekte lesbar (Begriffe: `assign_term`); strenger als Divi:
|
||||
Autoren-Bedingungen (`archive:user:id:`) nur mit `list_users` (Q10).
|
||||
- E9: eigene Gruppe „Divi Theme Builder“ (`divi-theme-builder`); alle neuen Tools indirekt, 22 direkte Tools.
|
||||
- E10/E11 umgesetzt mit `Fingerprint` (Struktur/Inhalt). „Inhaltsgleich“ heißt gleiche Bytes einschließlich
|
||||
Textteilen zwischen Blöcken und Inhalt von Core-Blöcken, nur Admin-Labels ausgenommen (Q25); ersetzt wird nur
|
||||
auf Seiten und Beiträgen (`page`, `post`), die das Konto bearbeiten darf – Theme-Builder-Layouts auch nicht als
|
||||
Quelle (Q24). Globale Einträge enthalten keinen Verweis auf einen Eintrag in irgendeiner Schreibweise (Q7).
|
||||
D10-Semantik beim Speichern mehrerer Seiten: Abbruch bei der ersten gescheiterten Seite, Fehler nennt
|
||||
geänderte, gescheiterte und unveränderte Seiten.
|
||||
- E12: Feldprüfung im Inhalt-Layout als Warnung gegen die Inhaltstypen der Vorlagen-Bedingungen, beide
|
||||
Feldformen (Q11).
|
||||
- P13 (vollständiges Ausblenden der Bibliotheks-IDs, auch im Suchen-und-Ersetzen) und P10 (Zielregeln der
|
||||
Vollform `post_link_url_*`, Bibliothekseinträge und Typen ohne Divi-Linkoption wie fehlende IDs, Q14)
|
||||
umgesetzt.
|
||||
- Restrisiken: P25 (ungewöhnliche Schreibweisen globaler Verweise), dazu Divis erneutes Auswerten von Blöcken in
|
||||
Shortcode-Modulen und WooCommerce-Textmodulen (dokumentiert im Guide `divi-library`); das Präfilter-Orakel von
|
||||
`lit/divi-replace-text` (Q13) und die Race-Bedingungen zwischen Prüfen und Schreiben bleiben für Etappe U.
|
||||
- Server-Instructions: Zeile „Divi Theme Builder: …“ nur bei aktiver, beschreibbarer Gruppe; der Ablauf
|
||||
„custom-post-type-vorlage“ steht im Guide `theme-builder`.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Etappe 3b – Manuelle Abnahme (Theme Builder, ähnliche Abschnitte, globale Module umwandeln)
|
||||
|
||||
Automatisch geprüft sind bereits:
|
||||
|
||||
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
|
||||
- `tb-smoke.sh` (neu) und die sechs bisherigen Integrationsskripte `divi-library-smoke.sh`,
|
||||
`divi-write-smoke.sh`, `divi-read-smoke.sh`, `mcp-smoke.sh`, `wp-basis-smoke.sh` und `media-admin-smoke.sh`
|
||||
(alle in `tests/Integration/`).
|
||||
|
||||
`tb-smoke.sh` prüft über den OAuth-Endpunkt, nur mit temporären Daten, die es danach wieder löscht. Die einzige
|
||||
Vorlage, die es aktiviert, gilt nur für eine temporäre Entwurfsseite; Besucher sehen davon nichts. Geprüft werden:
|
||||
|
||||
- Werkzeugliste: weiterhin 22 direkte Tools; `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`,
|
||||
`lit/tb-activate`, `lit/library-find-similar` und `lit/library-make-global` indirekt und erreichbar;
|
||||
`lit-site-status` ohne Befund bei Divi;
|
||||
- neue Vorlage: deaktiviert, in der Liste des Theme Builders, Inhalt-Layout über Divis eigene Funktionen mit dem
|
||||
Inhalt des Plugins, wird nicht angezeigt; die Divi-Werkzeuge ändern das Layout einer deaktivierten Vorlage
|
||||
sofort, „Inhalt posten“ (`post-content`) ist im Inhalt-Layout erlaubt;
|
||||
- `lit/tb-assign`: bei einer deaktivierten Vorlage sofort gespeichert, fehlendes `exclude_from` lässt die
|
||||
Ausnahmen stehen, `[]` entfernt sie, eine veraltete `template_rev` wird abgelehnt;
|
||||
- `lit/tb-activate`: erst Probelauf, dann Aktivieren; die Entwurfsseite zeigt die Vorlage in der Vorschau, die
|
||||
Startseite und Besucher sehen nichts; Änderungen am Layout (auch Löschen) und an den Bedingungen einer aktiven
|
||||
Vorlage erst nach Probelauf; Deaktivieren; die Standardvorlage wird abgelehnt;
|
||||
- ein ungespeicherter Entwurf im Theme Builder sperrt Anlegen, Zuordnen und Aktivieren; Deaktivieren geht
|
||||
trotzdem, mit Warnung;
|
||||
- eine Vorlage, die ein veraltetes Theme-Builder-Fenster aus der Liste entfernt hat: erscheint unter
|
||||
`detached`, Aktivieren und Zuordnen ohne `reattach` werden abgelehnt, mit `reattach: true` ist sie wieder in
|
||||
der Liste, deaktiviert und ohne „ungenutzt“-Markierung;
|
||||
- ein Redakteur ohne Theme-Builder-Recht: `lit/tb-list` und Anlegen abgelehnt, Vorlagen und Layouts gelten als
|
||||
nicht vorhanden; Gruppe „Divi Theme Builder“ auf „nur lesen“ bzw. deaktiviert wirkt auch auf die
|
||||
Divi-Werkzeuge;
|
||||
- Pods, mit einem eigenen temporären Pod: Divi bietet die Bedingung „Alle …“ für den neuen Inhaltstyp an; im
|
||||
Inhalt-Layout erzeugt das Beziehungsfeld eine Warnung, das Auswahlfeld einen Hinweis, das Textfeld nichts;
|
||||
- `lit/library-find-similar` (gleich und nur gleich aufgebaut) und `lit/library-make-global` (Umwandeln,
|
||||
dann Ersetzen nach Probelauf; ein nur ähnlicher Abschnitt wird nicht ersetzt);
|
||||
- P13 (keine Bibliotheks-IDs ohne Bibliothekszugang) und P10 (`post_link_url_page` nicht auf Beiträge);
|
||||
- am Ende: Liste des Theme Builders und Aktiv-Zustand jeder Vorlage wie vorher, Optionen wiederhergestellt,
|
||||
keine Beiträge, Metadaten, Begriffe, Protokollzeilen, Pods, Konten oder Rollen des Laufs übrig.
|
||||
|
||||
Die folgenden Punkte brauchen den echten Theme Builder bzw. Visual Builder und einen verbundenen KI-Assistenten.
|
||||
Abweichungen als kurze Notiz zurückmelden; sie werden Folgeaufgaben.
|
||||
|
||||
Wichtig: Eine aktive Vorlage ändert sofort, was Besucher sehen. Für die Abnahme Vorlagen nur für Entwurfsseiten
|
||||
oder für die „[Fixture] Personen“ aktivieren und im selben Schritt wieder deaktivieren. Die „[Fixture]“- und
|
||||
„[Test]“-Inhalte nicht ändern.
|
||||
|
||||
## Vorbereitung
|
||||
|
||||
- [ ] DDEV läuft, Divi 5.2.1 ist aktiv, Pods ist aktiv, LIT MCP zeigt Version 0.5.0 (Plugins-Seite).
|
||||
- [ ] Divi → Theme Builder öffnen und den Stand notieren: welche Vorlagen es gibt und welche aktiv sind
|
||||
(Schalter in der Vorlage). Ist dort etwas ungespeichert, speichern oder verwerfen. Danach das Fenster
|
||||
schließen – ein offenes Theme-Builder-Fenster kann beim späteren Speichern Vorlagen des Plugins wieder
|
||||
entfernen.
|
||||
- [ ] Unter Einstellungen → LIT MCP → Tools stehen die Gruppen „Divi“, „Divi-Bibliothek“ und „Divi Theme Builder“
|
||||
auf ihrem Standard (nicht „nur lesen“, nicht deaktiviert); in der Gruppe „Divi Theme Builder“ stehen
|
||||
`lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign` und `lit/tb-activate`.
|
||||
- [ ] Nach einem Lauf von `tb-smoke.sh` ist nichts übrig: unter Seiten, Divi → Divi-Bibliothek und Divi → Theme
|
||||
Builder nach „LIT Smoke“ suchen – keine Treffer; unter Pods Admin → Pods verwalten kein Pod „lit_smk_…“.
|
||||
- [ ] Den KI-Assistenten neu verbinden, damit er die neuen Tools und Hinweise lädt. In einer neuen Unterhaltung
|
||||
nennt der Hinweis des Servers den Theme Builder.
|
||||
- [ ] Eine Entwurfsseite „Abnahme 3b – Vorlage“ anlegen (nicht veröffentlichen). Sie ist das Ziel der
|
||||
Seiten-Vorlage unten.
|
||||
|
||||
## 1. Vom Plugin angelegte Vorlage im Theme Builder und Visual Builder
|
||||
|
||||
- [ ] Den KI-Assistenten bitten: „Lege im Theme Builder eine Vorlage ‚Abnahme 3b – Seite‘ für die Seite
|
||||
‚Abnahme 3b – Vorlage‘ an: globale Kopfzeile, im Inhalt eine Überschrift mit dem Seitentitel, ein Text und
|
||||
der Seiteninhalt, Fußzeile ausgeblendet.“ Erwartet: er nimmt `lit/tb-create-template` und sagt, dass die
|
||||
Vorlage deaktiviert ist und erst mit `lit/tb-activate` wirkt.
|
||||
- [ ] Divi → Theme Builder öffnen: die Vorlage steht in der Liste, deaktiviert, Bedingung „Seiten: Abnahme 3b –
|
||||
Vorlage“ (bzw. die Seiten-ID), Kopfzeile = globale Kopfzeile, Fußzeile ausgeblendet.
|
||||
- [ ] Das Inhalt-Layout im Visual Builder öffnen: Überschrift mit dynamischem Inhalt „Titel“, Text und das
|
||||
Modul „Inhalt posten“ sind vorhanden und bearbeitbar; der Builder meldet keine Fehler. Ohne Speichern
|
||||
schließen.
|
||||
- [ ] Im Theme Builder eine harmlose Änderung an einer anderen, deaktivierten Vorlage machen (z. B. Titel) und
|
||||
„Änderungen speichern“: die neue Vorlage bleibt in der Liste. Die Änderung danach zurücknehmen.
|
||||
- [ ] Den Assistenten bitten, die Vorlage aufzulisten: `lit/tb-list` zeigt sie deaktiviert, mit Layout-IDs und
|
||||
`template_rev`.
|
||||
|
||||
## 2. Aktivieren mit Probelauf
|
||||
|
||||
- [ ] Den Assistenten bitten, die Vorlage zu aktivieren. Erwartet: er zeigt zuerst den Probelauf (Bedingungen,
|
||||
Layouts, Überschneidungen mit anderen aktiven Vorlagen) und fragt nach, bevor er etwas speichert.
|
||||
- [ ] Nach der Nachfrage ablehnen: im Theme Builder ist die Vorlage weiter deaktiviert.
|
||||
- [ ] Erneut bitten und zustimmen: im Theme Builder ist sie aktiv. Die Vorschau der Entwurfsseite zeigt die
|
||||
Vorlage (Überschrift mit dem Seitentitel, Text, Seiteninhalt, keine Fußzeile). Die Startseite und andere
|
||||
Seiten sehen aus wie vorher.
|
||||
- [ ] Den Assistenten bitten, im Inhalt-Layout den Text zu ändern: er meldet, dass die Vorlage aktiv ist, zeigt
|
||||
den Probelauf und fragt nach. Nach Zustimmung zeigt die Vorschau den neuen Text; unter „Revisionen“ des
|
||||
Layouts steht der alte Stand.
|
||||
- [ ] Den Assistenten bitten, die Bedingungen der aktiven Vorlage zu ändern (z. B. eine Ausnahme hinzufügen):
|
||||
Probelauf mit vorher/nachher, erst nach Zustimmung gespeichert.
|
||||
- [ ] Den Assistenten bitten, die Vorlage wieder zu deaktivieren (Probelauf, Zustimmung): im Theme Builder ist
|
||||
sie deaktiviert, die Vorschau der Entwurfsseite zeigt wieder das normale Seitenlayout.
|
||||
- [ ] Unter Einstellungen → LIT MCP → Protokoll stehen Anlegen, Aktivieren, Layout-Änderung, Bedingungen und
|
||||
Deaktivieren.
|
||||
|
||||
## 3. Bedingungen für einen Pods-Inhaltstyp
|
||||
|
||||
- [ ] Den KI-Assistenten bitten: „Lege eine Vorlage für alle [Fixture] Personen an: globale Kopfzeile, im Inhalt
|
||||
Name, Funktion, Stufe und Mentor, Fußzeile wie bisher.“ Erwartet: er liest vorher die Felder
|
||||
(`lit/divi-dynamic-sources` für `lit_fixture_person`), legt die Vorlage deaktiviert an und weist darauf hin,
|
||||
dass Divi beim Mentor nur die ID zeigt und bei der Stufe den gespeicherten Wert statt der Beschriftung.
|
||||
- [ ] Divi → Theme Builder: die Vorlage hat die Bedingung „Alle [Fixture] Personen“ und ist deaktiviert.
|
||||
- [ ] Das Inhalt-Layout im Visual Builder öffnen: die Module zeigen dynamischen Inhalt „Benutzerdefiniertes
|
||||
Feld“ mit „role“, „level“ und „mentor“ und lassen sich im Dialog bearbeiten. Ohne Speichern schließen.
|
||||
- [ ] Den Assistenten bitten, die Bedingung auf genau eine Person zu ändern (Erika Muster), dann zu aktivieren
|
||||
(Probelauf, Zustimmung): die Seite dieser Person zeigt Name und Funktion („Geschäftsführung“), die Stufe als
|
||||
„senior“; die Seite von Max Beispiel sieht aus wie vorher. Danach sofort wieder deaktivieren lassen.
|
||||
|
||||
## 4. Ähnliche Abschnitte zu globalen Modulen machen
|
||||
|
||||
- [ ] Zwei Entwurfsseiten „Abnahme 3b – A“ und „Abnahme 3b – B“ mit demselben Abschnitt bauen (im Visual Builder
|
||||
den Abschnitt von A kopieren und in B einfügen), dazu eine Seite „Abnahme 3b – C“ mit demselben Abschnitt,
|
||||
aber einem geänderten Text.
|
||||
- [ ] Den Assistenten bitten, wiederkehrende Abschnitte zu finden und zu globalen Modulen zu machen. Erwartet:
|
||||
`lit/library-find-similar` nennt B als gleich und C als nur gleich aufgebaut; er schlägt vor, den Abschnitt
|
||||
von A umzuwandeln, und fragt nach.
|
||||
- [ ] Nach Zustimmung: `lit/library-make-global` legt einen globalen Eintrag an und setzt ihn auf Seite A als
|
||||
Instanz ein; für Seite B zeigt er zuerst den Probelauf und ersetzt erst nach Zustimmung. Seite C bleibt
|
||||
unverändert.
|
||||
- [ ] Divi → Divi-Bibliothek: der Eintrag ist ein globaler Abschnitt.
|
||||
- [ ] Seiten A und B im Visual Builder öffnen: der Abschnitt ist als globales Modul gekennzeichnet; eine
|
||||
Änderung daran im Builder erscheint nach dem Speichern auf beiden Seiten. Unter „Revisionen“ von B steht der
|
||||
Stand vor dem Ersetzen.
|
||||
- [ ] Den Assistenten bitten, in eine andere Entwurfsseite einen neuen Abschnitt einzufügen, der wie der
|
||||
Bibliotheksabschnitt aufgebaut ist: die Antwort weist auf den Bibliothekseintrag hin.
|
||||
|
||||
## 5. Rechte
|
||||
|
||||
- [ ] Optional mit einem Redakteurs-Konto (Rolle unter Einstellungen → LIT MCP → Zugriff freigeben, den
|
||||
Assistenten in einem privaten Browserfenster mit diesem Konto verbinden): der Assistent sieht keine Vorlagen
|
||||
und erklärt, dass die Berechtigung für den Theme Builder fehlt. Danach die Freigabe zurücknehmen und wieder
|
||||
als Administrator verbinden.
|
||||
- [ ] Gruppe „Divi Theme Builder“ auf „nur lesen“: `lit/tb-list` geht, Anlegen und Aktivieren fehlen, Änderungen
|
||||
an Theme-Builder-Layouts mit den Divi-Werkzeugen werden abgelehnt. Danach auf den Standard zurückstellen.
|
||||
|
||||
## Aufräumen
|
||||
|
||||
- [ ] Divi → Theme Builder: die Vorlagen „Abnahme 3b …“ und die Personen-Vorlage löschen und „Änderungen
|
||||
speichern“. Danach dieselben Vorlagen aktiv wie in der Vorbereitung notiert.
|
||||
- [ ] Die Seiten „Abnahme 3b …“ und den globalen Eintrag in der Divi-Bibliothek löschen (auch aus dem Papierkorb).
|
||||
- [ ] Optional: die Einträge der Abnahme im Protokoll-Tab über „Protokoll leeren“ entfernen.
|
||||
- [ ] Ob Pods wieder deaktiviert werden soll, entscheidet der Benutzer; die Fixture-Daten („[Fixture] Personen“)
|
||||
bleiben stehen, bis ihr Aufräumen laut `tests/Fixtures/divi/stage3b/README.md` gewünscht ist.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Etappe 3b – Entscheidungen (Rulings), Befunde und offene Punkte
|
||||
|
||||
Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3b-security-audit.md. Abnahme: 2026-09-27-etappe-3b-abnahme.md.
|
||||
|
||||
## Offen für spätere Etappen
|
||||
- Etappe U: Revisionen von Theme-Builder-Layouts und Bibliothekseinträgen über MCP wiederherstellen (jetzt nur restore_url zum WordPress-Revisionsbildschirm, Audit L3); Oracle über Vorfilter/next_cursor von replace-text (Q13); Race beim Anhängen von Vorlagen; try/catch um Divi-Aufrufe; tree_rev-Race; DNS-Pinning; Build-Hygiene.
|
||||
- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren.
|
||||
- Bewusst belassen: {{field:}} im Body-Layout warnt nur (Anzeige-Parität mit Divi, Audit I1); Bestätigung ist nicht an den Probelauf gebunden (wie 3a, Audit I3); gemeinsamer confirm-Helfer verworfen (Q17 M4).
|
||||
|
||||
## Ledger
|
||||
|
||||
Spec: docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md; decisions: docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (§5 user answers binding, F1: TB templates disabled by default + explicit activation tool); rulings 1a/1b/2a/2b/3a in docs/superpowers/notes/.
|
||||
Branch: etappe-3 (base 0edb474; 3a merged to main 4f05388), in place. Pods 3.3.9.2 activated locally by user permission (2026-09-27), no pods defined yet.
|
||||
Process rules carried over: opus reviewers for security-relevant tasks; controller verifies live with benign probes if reviewer agents stop; smoke guard; commits by explicit path; final review + security audit + one fix wave (breaker after round 2).
|
||||
|
||||
## Pre-flight rulings
|
||||
Pre-flight (opus): 1 Critical, 6 Important, 16 Minor, 7 Info; all anchors match once; full tables in preflight.md (B1–B23).
|
||||
- Ruling Q1 (B1, Critical): 3b fixtures use their own pod `lit_fixture_person` („[Fixture] Personen“) that no smoke deletes; wp-basis-smoke's shared lit_person stays as is — cost if wrong: none
|
||||
- Ruling Q2 (B2): extract GlobalEdits' backup + confirm-merge into a shared helper; live TB layout edits reuse it (backup when revisions off, P9 refusals) — cost if wrong: none
|
||||
- Ruling Q3 (B3): lit/tb-assign leaves omitted exclude_from unchanged; dry run shows both lists (before/after) — cost if wrong: none
|
||||
- Ruling Q4 (B4): TemplateBuildTest uses assertPartials — cost if wrong: none
|
||||
- Ruling Q5 (B5, USER-APPROVED 2026-09-27): Task 1 Step 8 may enable the fixture template briefly, conditions limited to exactly one „[Fixture]“ person, disabled again in the same step (try/finally), verified off afterwards; Step 6 does not change Divi's post-type setting — cost if wrong: momentary visible change on one fixture URL
|
||||
- Ruling Q6 (B6): tb-smoke creates/deletes its own temporary pod, never touches lit_person or lit_fixture_person — cost if wrong: none
|
||||
- Ruling Q7 (B7): UsageMatcher::referencesAny() refuses any global reference in every spelling Divi renders; used by lit/library-make-global and lit/library-save scope global (no self-reference, no nested refs in shortcode text) — cost if wrong: none
|
||||
- Ruling Q8 (B8–B21 minors): adopt as proposed in preflight.md (template live = attached+enabled regardless of post status; layouts must be published before reattach/activate; tb-list hides IDs of templates the user can't edit; test/count/doc fixes; smoke library-id check via jq) — cost if wrong: small
|
||||
- Ruling Q9 (B22 / plan open point): live-visible TB edits use dry run + confirm (with Q2 backup), not refusal — matches F2 — cost if wrong: none
|
||||
- Ruling Q10 (B23 / plan open point): author conditions require list_users — only admins hold Divi's theme_builder right by default — cost if wrong: editors with theme_builder can't set author conditions
|
||||
|
||||
## Progress
|
||||
- Task 1: dispatched (opus, BASE 0edb474; Q1 own pod, Q5 user-approved brief activation)
|
||||
- Task 1: done (c615522; fixtures: pod lit_fixture_person 2381, persons 2385/2386, body layout 2391, template 2394 disabled, global header 1061; brief activation only for Erika, off again; live site unchanged). Finding 10: stale TB editor save detaches our template silently + marks unused (7-day trash) → confirms plan mitigation (detached/reattach). Finding 12: Divi dialog writes Pods fields as post_meta_key + select_meta_key "custom_meta_<key>" (empty meta_key); plugin writes custom_meta_<key>; choice shows stored value; relation shows raw ID
|
||||
- Ruling Q11: Task 11 (Pods field check) and any usage/field detection recognize both forms (custom_meta_<key> and post_meta_key/select_meta_key); S29/S30 meta-key rules already cover both on write — cost if wrong: missed warnings
|
||||
- Ruling Q12: Task 1 (fixtures/test data only) verified by controller from report + commit instead of a separate review; fixtures are re-checked by later task reviews — cost if wrong: none
|
||||
- Task 1: complete (0edb474..c615522)
|
||||
- Tasks 2–3: dispatched (opus, BASE c615522, one commit each)
|
||||
- Tasks 2–3: implementer done (06f1379 P10 PostIdParser + type-exact post_link_url; c7ce924 P13 revealsIds + replace-text skipped_nodes ID-oracle fix; 1923 tests); concern: replace-text prefilter/next_cursor partial oracle → U (Ruling Q13)
|
||||
- Tasks 2–3: review (opus): both Approved, live-verified (P10 nothing widened; P13 no ID in any path, digit oracle fixed, nur lesen reveals). Important (pre-existing): {{link:<library ID>}} type oracle ("Unbekannter Name post_link_url_et_pb_layout" vs "gibt es nicht")
|
||||
- Ruling Q14: WpLinkTargets treats et_pb_layout (and any post type without a Divi post_link_url option, incl. internal _-types) as not found → uniform message; minors: merge stacked docblocks WordPressIntegration.php:52-56, DiviTextSearch withoutLibraryIds doc (hides more for undecodable opaque openers) — fix round 1 now — cost if wrong: none
|
||||
- Ruling Q13 (confirmed): replace-text prefilter/next_cursor oracle deferred to U, documented as parity with WordPress' own content search
|
||||
- Tasks 2–3: fix round 1 (Q14; 82ef69a; 1926 tests): WpLinkTargets linkable = Divi's et_builder_get_public_post_types() minus attachment/_-types/et_pb_layout; identical answers library vs missing ID (test); DiviTextSearch strips IDs only in global nodes — verified by controller against report/tests
|
||||
- Tasks 2, 3: complete (c615522..82ef69a)
|
||||
- Task 4: dispatched (opus, BASE 82ef69a; wp_die guard, ThemeBuilderAccess, Q8 minors)
|
||||
- Task 4: implementer done (d04a508; 1958 tests); helpers for T7/T9/T10: ThemeBuilderAccess::canEditTemplate(), layoutsRefusal(); attach race → U
|
||||
- Task 4: review (opus): Approved; wp_die guard live-verified (CLI/AJAX/JSON, nested, no output/status leak, handlers restored, nothing written); ThemeBuilderAccess = Divi's save gate; P12 no oracle; isLive matches Divi
|
||||
- Ruling Q15: ThemeBuilderAccess::canUse additionally requires edit_theme_options (Divi's TB admin page gate) — never laxer than Divi's UI — cost if wrong: roles with theme_builder but without edit_theme_options can't use TB tools
|
||||
- Ruling Q16 (T4 minors): T10 must be able to deactivate an attached+enabled template that sits in the trash (raw lookup for the off-switch, fail-safe); docblock note on third-party wp_die inside hooks; canEditTemplate docblock (IDs from Divi's list); dedupe GlobalUsages::THEME_BUILDER_TYPES/DiviSaver cache clear/compat row → final wave
|
||||
- Task 4: fix round 1 (Q15 + docblocks) dispatched
|
||||
- Task 4: fix round 1 (Q15; 4ff1c1e; 1959 tests) — verified by controller
|
||||
- Task 4: complete (82ef69a..4ff1c1e)
|
||||
- Task 5: dispatched (opus, BASE 4ff1c1e; Q2 shared backup/confirm helper, Q9, Q8)
|
||||
- Task 5: implementer done (b1dd35a; 1980 tests; smoke ok); VisibleEdits shared helper (GlobalEdits/ThemeBuilderEdits/DiviWriter); TB layouts get Divi flags (as Divi writes); T11 adds ?DynamicFields after VisibleEdits param
|
||||
- Task 5: review (opus): Approved; live: 9 write paths on live layout → confirm_required/merged confirm/replace-text refused, confirmed save with revision, backup when revisions off; access P12 no oracle; post-content body-only on all paths; GlobalEdits byte-identical after Q2
|
||||
- Ruling Q17 (T5 minors): M1 GlobalUsages P8a TB rows reuse ThemeBuilderAccess::canUse (not only theme_builder) → final wave (security, must); M2 confirm description mentions live TB layouts → T16; M3 library-path P9/P14 wording test → final wave; M4 optional VisibleEdits::confirmed → final wave
|
||||
- Task 5: complete (4ff1c1e..b1dd35a)
|
||||
- Tasks 6–7: dispatched (opus, BASE b1dd35a, one commit each; Q10, Q8, group wiring)
|
||||
- Tasks 6–7: implementer done (ac69fdf TemplateConditions: 34 Divi IDs, 27 round-trip live, assign_term for terms, list_users for authors, uniform missing/unreadable; 54922a9 tb-list + group live: nur lesen blocks TB writes, disabled → not_found; 1998 tests)
|
||||
- Tasks 6–7: review (opus): both Approved; live: 34 Divi condition IDs covered by construction, parsing safe, uniform missing/unreadable, tb-list leaks nothing, detached shown, group matrix verified, never enabled a template
|
||||
- Ruling Q18: ThemeBuilderAccess::template() answers not_found (identical to missing) for templates the user can't edit (canEditTemplate), consistent with tb-list (Q8/B11) — implement in T8 (first caller) incl. test; minors → final wave: DETACHED_LIMIT applied after edit filter + truncation note, invalid id message, role-slug regex doc
|
||||
- Tasks 6, 7: complete (b1dd35a..54922a9)
|
||||
- Task 8: dispatched (opus, BASE 54922a9; F1, Q4, Q8, Q18, rollback reuse)
|
||||
- Task 8: implementer done (42b9b38; 2020 tests; live create: disabled+attached, tb-list shows, fixture page byte-identical, cleanup complete); controller deleted 3 leftover change-log rows of the live check (1248–1250)
|
||||
- Task 8: review (opus): never-enabled verified on all paths incl. forced read-back; rollback per step live; unsaved-editor detection = Divi's hasDraft; rights live. Important: post inserted by Divi before it fails (throw inside store_template/insert_layout after wp_insert_post) not tracked → orphan published et_template (disabled/detached, no visible effect); minors: orphan layout_type term relationships after delete, partial() names deleted IDs, refuse() loses details, default template editability
|
||||
- Ruling Q19: while guarded() runs, record IDs of posts inserted of the expected types (wp_insert_post action) and hand them over (exception subclass carrying IDs) so TemplateBuild rolls them back; undo() also removes layout_type term relationships; partial() lists only IDs whose delete failed; refuse() keeps all failure details; defaultTemplate() respects canEditTemplate — fix round 1 — cost if wrong: none
|
||||
- Task 8: fix round 1 (Q19; 2827fd4; 2030 tests; live wp_die-in-hook on template and layout save → write_failed, nothing left) — verified by controller; residual (throw between DB insert and first cache clean) documented, accepted
|
||||
- Task 8: complete (54922a9..2827fd4)
|
||||
- Tasks 9–10: dispatched (opus, BASE 2827fd4, one commit each; Q3, Q9, Q8, Q16, Q18)
|
||||
- Tasks 9–10: implementer done (0975b2f tb-assign; 64fc439 tb-activate; 2056 tests; live on temp draft only, visible site unchanged, cleanup verified)
|
||||
- Ruling Q20: the off-switch (tb-activate enabled:false) is exempt from the unsaved-editor refusal (E7) — reducing visibility must always be possible; result warns that saving an open Theme Builder editor may restore its own state — cost if wrong: a stale editor may re-enable (warned)
|
||||
- Tasks 9–10: review (opus): spec ✅ both; live: no path makes a template visible except confirmed activate (reattach disables first), Q3/Q9 dry runs, Q16 trash off-switch no oracle, rights, change log; visible site unchanged at every step; changes requested: overlap lists leak uneditable live templates (Q8/B11), Q20 not yet implemented
|
||||
- Ruling Q21: one shared ThemeBuilderAccess::editableLiveTemplates() used by tb-list, tb-assign and tb-activate dry runs; uneditable live templates only as a count („weitere aktive Vorlagen ohne Berechtigung“); Q20 implemented (off-switch skips draft refusal + warning); minors: dedupe overlaps vs possible_overlaps, reattach records previous enabled state in previous/log, stricter exclude_from null handling, live from $after->isLive() — fix round 1
|
||||
- Tasks 9–10: first fix-round implementer stopped by user mid-way; user said 'weiter' → fresh opus implementer continues from uncommitted partial work (Q20, Q21, minors)
|
||||
- Tasks 9–10: fix round 1 (Q20, Q21, minors; a31d434; 2068 tests); tb-list hidden_live count; re-review running
|
||||
- Tasks 9–10: re-review clean (Q21 no leak, counts only; Q20 live with a real Divi draft; minors fixed)
|
||||
- Ruling Q22 (must, final wave): ThemeBuilderEdits::showing()/listing() use editableLiveTemplates() (count for hidden) — same leak class as Q21, in layout-edit confirm details — cost if wrong: none
|
||||
- Tasks 9, 10: complete (2827fd4..a31d434)
|
||||
- Task 11: dispatched (opus, BASE a31d434; Q11 both forms, Q21 pattern)
|
||||
- Task 11: implementer done (6be2e65; 2084 tests; live read-only on 2391); DynamicFields::metaKeyOf reads both forms (Q11), choice/relation notes, editable templates only, dry run shows field warnings
|
||||
- Ruling Q23: the plan's review checkpoint after Task 11 is covered by per-task reviews + the final whole-branch review (every task so far reviewed with live checks) — no separate checkpoint review — cost if wrong: late discovery of cross-task drift (final review)
|
||||
- Task 11: review (sonnet): Approved; metaKeyOf matches Divi's DynamicContentOptionPostMetaKey branch for branch, live fixture 2391 unchanged, no leak, named-arg wiring correct
|
||||
- Task 11: complete (a31d434..6be2e65)
|
||||
- Tasks 12–13: dispatched (opus, BASE 6be2e65, one commit each; bounded, no leaks, cheap write-time warning)
|
||||
- Tasks 12–13: implementer done (f82964f find-similar: caps 200 posts/200 matches/5000 nodes, batch loads; 4f70886 write-time warning: ≤200 library sections cached per request; 2102 tests; smoke ok)
|
||||
- Tasks 12–13: review (sonnet): both Approved; live find-similar as admin + temp author: no oracle (P12 forbidden for foreign reference), pagination exact, excluded types uniform; minors → final wave: excluded-type message names "" type; multi-type own-posts restriction documented
|
||||
- Tasks 12, 13: complete (6be2e65..4f70886)
|
||||
- Tasks 14–15: dispatched (opus, BASE 4f70886, one commit each; Q7 referencesAny, reuse library-save/GlobalInstance/VisibleEdits, per-page results)
|
||||
- Tasks 14–15: implementer done (44c1804 make-global + NewLibraryEntry shared + referencesAny; e733065 replace identical occurrences: pre-check all targets, per-page commit+revision, stop at first failure with changed/failed/unchanged lists; 2160 tests; smokes ok)
|
||||
- Ruling Q24: make-global refuses Theme Builder layouts also as SOURCE (stricter than brief; keeps TB edits on the TB tools) — cost if wrong: a TB section can't be made global directly (copy to a page first)
|
||||
- Tasks 14–15: review (opus, SEC): T14 Approved (referencesAny 27 spellings live, rights, rollback); T15 Important: Fingerprint::body ignores text parts / core-block inner HTML → "identical" target with different visible content replaced (live, silent content swap); minors: revision note on page-N failure, "pages and posts only" wording vs Divi-enabled types; Low: Divi re-runs do_blocks in shortcode module/Woo checkout text → escaped global-layout inside JSON value expands (document with P25)
|
||||
- Ruling Q25: Fingerprint content hash includes non-whitespace text parts and full bytes of opaque blocks (structure hash unchanged); plans() additionally compares normalized serialized bytes of the node; regression tests for both variants; find-similar "identical" uses the same fix; failed-page message keeps "Revision X bleibt erhalten"; scope check matches wording (only page/post types, or wording = Divi-enabled types) — decide pages+posts only; Low item documented with P25 — fix round 1 — cost if wrong: none
|
||||
- Tasks 14–15: fix round 1 (Q25; 4e95983; 2170 tests; smokes ok; live: altered copies no longer identical, forced confirm refused, pages unchanged) — verified by controller against the reviewer's repro scenario
|
||||
- Tasks 14, 15: complete (4f70886..4e95983)
|
||||
- Tasks 16 (docs, opus) and 17 (tb-smoke + acceptance, opus) dispatched in parallel, BASE 4e95983 (disjoint files)
|
||||
- Task 16: implementer done (3db96a3; 2173 tests; v0.5.0); docs honest about open Q22/Q17-M1 (final wave)
|
||||
- Task 16: review approved (spec ✅, quality approved; 2173 tests re-run green; only cosmetic line-wrap nit)
|
||||
Task 16: complete
|
||||
- Task 17: implementer done (0b7a1c2; 2173 tests; all 7 smokes green); deviations: enabled template on temp draft page via preview, temp pod never enabled, read-only → not found. Review dispatched (sonnet)
|
||||
- Task 17: review approved (spec ✅, quality approved; smoke re-run live green, no leftovers, TB baseline 1065=0 1066=0 2394=0)
|
||||
Ruling: Claude-Session trailer after Co-Authored-By is correct — the session's current attribution instruction requires both lines; common.md predates it — cost if wrong: cosmetic
|
||||
Task 17: complete
|
||||
- Final whole-branch review + security audit dispatched in parallel (opus), range 0edb474..0b7a1c2
|
||||
- Final review done: 0 critical, 3 important (I-1 Q22, I-2 Q17M1 + TB-ID leak to library-only editors, I-3 Q17M3), 12 minor; report final-review.md. Ruling: drop Q17 M4 (shared confirm helper) — 13 call sites project-wide, out of scope — cost: minor duplication stays. Waiting for security audit before fix wave.
|
||||
- Security audit done: 0 C/H/M, 4 Low (L1=Q17M1, L2=Q22, L3 TB/library revisions not restorable via MCP, L4 reattach accepts TB-library templates), 3 Info; report security-audit.md
|
||||
Ruling: fix wave covers I-1..I-3, L1..L4, all final-review minors except Q17 M4 — both reports agree, all small — cost if wrong: extra churn
|
||||
Ruling: L3 gets the minimal fix (restore_url to WP revision screen + corrected docs), not MCP restore for TB/library types — the full route needs new access wiring, belongs to Etappe U — cost: restore stays a manual step in wp-admin
|
||||
Ruling: I1 stays a warning (display parity, preflight ruling), I2 documented in guide, I3 unchanged like 3a — cost: none beyond 3a status quo
|
||||
- Final fix wave dispatched (opus), BASE 0b7a1c2
|
||||
- Fix wave done: 7590da4 43dc3fb 79f2fdb dcae2d4 e64d32b 5290a9d e7bbe1e 14bc954 6288acf 50cfa42 beb8706; 2199 tests, lint clean, tb/library/write smokes green, TB baseline off
|
||||
Ruling: accept stricter I-2 (read-only TB group also blocks global edits reaching TB layouts) — matches the guide and group semantics — cost: a global edit may be blocked where the owner expected it to pass; one-line revert noted in fixwave-report
|
||||
Ruling: accept M-9 forbidden for accounts without any TB rights (tool-level, ID-independent, same as tb-list) — no existence oracle since answer does not depend on the ID; Q18 not_found stays for per-template refusals — cost: answer-code change for clients
|
||||
- Scoped re-review dispatched (opus), range 0b7a1c2..beb8706
|
||||
- Re-review: Approved (all in-scope findings addressed; N-1 minor docblock, N-2/N-3 info). Ruling: fix N-1, partial-write restore_url (completes L3) and make-global backup hint now via resumed fix-wave agent; N-2 as guide sentence; N-3 left — all tiny, closes L3 cleanly — cost: one more small commit set
|
||||
@@ -0,0 +1,275 @@
|
||||
# Etappe 3b (Divi Theme Builder): security audit
|
||||
|
||||
- **Scope:** branch etappe-3, `0edb474..0b7a1c2` (HEAD). New: `src/Integrations/Divi/ThemeBuilder/*`; library find-similar and make-global (`Library/LibraryFindSimilarAbility`, `LibraryMakeGlobalAbility`, `NewLibraryEntry`, `SimilarSections`, `UsageMatcher::referencesAny`); `Pages/VisibleEdits`, `Pages/DynamicFields`, `Pages/LayoutBuilder`, `BlockTree/Fingerprint`, `Compose/PostIdParser`, `WpLinkTargets`, and the loader, writer, saver, exposure and wiring changes (`DiviPageLoader`, `DiviWriter`, `SaveScope`, `DiviIntegration`, `Plugin`).
|
||||
- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1, Pods 3.3.9.2), single site, only user `admin` (no editor or author accounts), `DISALLOW_UNFILTERED_HTML` off, `et_pb_role_settings` empty (Divi defaults), `EMPTY_TRASH_DAYS` 30. Live Theme Builder post 1064 lists templates 1065 (default), 1066 and 2394, all with `enabled: false`, and there is no Theme Builder draft.
|
||||
- **Method:**
|
||||
- Read every new or changed `src/` file in the range and traced each write path to its checks.
|
||||
- Read the Divi code the plugin mirrors or relies on: `theme-builder.php` (`et_theme_builder_get_template`, `_get_template_layouts`, `_trash_draft_and_unused_posts`, `store_template`), `ThemeBuilderRequest::get_template`, `local-library.php`, `core.php` `et_pb_is_allowed`, `DynamicContentOptionsController`, and `DynamicContentOptionPostMetaKey`.
|
||||
- Ran four read-only `wp eval-file` probes. The probe files were temporary files in the web container's `/tmp`, deleted afterwards. They covered the live template state, the post type caps of the TB types, `wp_revisions_enabled` on the TB layouts, and Divi's flat condition list.
|
||||
- Simulated the editor and author roles with an in-memory `WP_User` (ID 987654, never saved), because the site has no such accounts.
|
||||
- Nothing was written to the site. `git status` is clean. `vendor/bin/phpunit`: 2173 tests, OK.
|
||||
|
||||
**Result:** no Critical, High or Medium findings.
|
||||
- The known items hold as recorded: Q22 and Q17 M1 are confirmed, both Low, with exact fixes below.
|
||||
- The deferred items (Q13 prefilter oracle, attach race, try/catch around Divi calls, DNS pinning, tree_rev race, preset gates) are not more severe than recorded. None of them reaches Theme Builder objects through a new path; see "Deferred items re-checked".
|
||||
- Findings: four Low (two of them Q17 M1 and Q22) and three Info notes.
|
||||
|
||||
---
|
||||
|
||||
## Findings (most severe first)
|
||||
|
||||
### L1 – Low (authorization, disclosure): P8a treats Theme Builder rows with Divi's `theme_builder` option alone, and the dry run names Theme Builder layouts to accounts that ThemeBuilderAccess hides them from (ruling Q17 M1, confirmed)
|
||||
|
||||
**Where**
|
||||
- `src/Integrations/Divi/Library/GlobalUsages.php:218-225` (`editable()`): a TB row counts as editable when `roles->allows('theme_builder')` and `edit_post` both hold. It does not check `edit_others_posts`, `edit_theme_options` (ruling Q15), or the group "Divi Theme Builder" (disabled or "nur lesen").
|
||||
- `GlobalUsages.php:98-106` (`report()`): a row is listed with `post_id`, `title`, `status` and `type` whenever `read_post` holds. For the TB layout types (capability type `post`, published, `map_meta_cap`) `read_post` is plain `read`. Checked in-memory: author `read_post(1061) = true`.
|
||||
- Wiring: `src/Integrations/Divi/DiviIntegration.php:197` (GlobalEdits) and `src/Integrations/Divi/Library/LibraryIntegration.php:64` (LibraryListAbility) both build `new GlobalUsages(null, $roles)` without the Theme Builder access.
|
||||
- Compare `ThemeBuilderAccess::canUse()` (`ThemeBuilderAccess.php:51-61`), and `layout()`/`visible()` (`:63-67`, `:87-98`). Every direct TB path answers a TB ID like a missing one without these rights (P12).
|
||||
|
||||
**Scenario**
|
||||
1. The site owner enables the Divi role option `theme_builder` for editors in Divi's role editor. Editors still lack `edit_theme_options`, so they cannot open Divi's Theme Builder page, and every `lit/tb-*` tool and every Divi tool on a TB layout refuses them (Q15).
|
||||
2. A global module sits in the Theme Builder header of a live template.
|
||||
3. The editor has `edit_global_library` and `edit_post` on the entry. They edit the entry with any Divi tool and confirm.
|
||||
4. P8a passes the header row (`theme_builder` on, `edit_post` via `edit_others_posts`). The site-wide header changes, which Q15 says this account may not do.
|
||||
5. The same holds when the group "Divi Theme Builder" is set to "nur lesen" or disabled for the connection.
|
||||
6. Disclosure, any account with library rights (authors too): the dry run's `affected` lists the TB layout's ID, title and type. `blocked_ids` names it by ID. For the same account, `lit-divi-outline post_id=<that ID>` answers "nicht gefunden".
|
||||
7. Mitigation: Divi's own Visual Builder allows the same global edit from any page (parity with Divi, as audit 3a L6). The impact is the plugin's own stricter rule P8a/Q15, not an escalation beyond Divi.
|
||||
|
||||
**Evidence:** by reading. Post type caps and `read_post` for editor and author were checked live, in-memory. The DDEV data has no global instance inside a TB layout, so no end-to-end probe was run.
|
||||
|
||||
**Fix**
|
||||
- Give GlobalUsages the Theme Builder decision as a closure, so the Library namespace keeps no dependency on ThemeBuilder:
|
||||
```php
|
||||
// GlobalUsages
|
||||
/** @param (Closure(): bool)|null $themeBuilder Whether TB objects are open to this account (ThemeBuilderAccess). */
|
||||
public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null, private ?Closure $themeBuilder = null)
|
||||
…
|
||||
private function themeBuilderOpen(): bool
|
||||
{
|
||||
return $this->themeBuilder !== null
|
||||
? ($this->themeBuilder)()
|
||||
: $this->roles->allows('theme_builder') // fail-safe default = ThemeBuilderAccess::canUse()
|
||||
&& current_user_can('edit_others_posts')
|
||||
&& current_user_can('edit_theme_options');
|
||||
}
|
||||
|
||||
private function isThemeBuilder(array $row): bool
|
||||
{
|
||||
return in_array($row['type'], self::THEME_BUILDER_TYPES, true);
|
||||
}
|
||||
|
||||
private function editable(array $row): bool
|
||||
{
|
||||
if ($this->isThemeBuilder($row) && ! $this->themeBuilderOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current_user_can('edit_post', $row['id']);
|
||||
}
|
||||
```
|
||||
- In `report()`, replace `:98` with `$readable = (! $this->isThemeBuilder($row) || $this->themeBuilderOpen()) && current_user_can('read_post', $id);`. TB rows the account cannot use then only count: `hidden_count`, `blocked_hidden`. They are still blocked, never named (P12, as ThemeBuilderAccess).
|
||||
- Wiring, at both call sites (`DiviIntegration.php:197`, and through `DiviIntegration::library()` → `LibraryIntegration.php:64`):
|
||||
```php
|
||||
new GlobalUsages(null, $this->access->roles(), fn (): bool => $this->themeBuilderAccess->visible()
|
||||
&& $this->themeBuilderAccess->writableGate() === null)
|
||||
```
|
||||
`visible()` already contains `canUse()`. The `writableGate()` part makes "nur lesen"/disabled for the TB group also block global edits that would change a live TB layout. If the controller wants only the Q17 M1 scope, use `canUse() === null` for `editable()` and `visible()` for the listing.
|
||||
- Tests:
|
||||
- A TB row with `theme_builder` on but without `edit_theme_options` is blocked (hidden count, no ID).
|
||||
- A TB row with the group "nur lesen" is blocked.
|
||||
- An author sees no TB row in `affected`.
|
||||
- A page row is unchanged.
|
||||
|
||||
### L2 – Low (disclosure): the confirm details of a live TB layout list every live template, including templates the user may not edit (ruling Q22, confirmed)
|
||||
|
||||
**Where**
|
||||
- `src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php:43-52` (`showing()`: all live templates that show the layout).
|
||||
- `:59-62` (`prepare()`: `details['templates'] = self::listing($templates)`).
|
||||
- `:96-108` (`notice()`: the same for lit-divi-delete's own confirm).
|
||||
- `:274-285` (`listing()`: `id`, `title`, `use_on` with object IDs).
|
||||
- Passed on unchanged by `src/Integrations/Divi/Pages/DiviReplaceTextAbility.php:402-408` (`templates` in the per-page refusal).
|
||||
- Compare `ThemeBuilderAccess::editableLiveTemplates()` (`ThemeBuilderAccess.php:193-201`). tb-list, tb-assign, tb-activate and `fieldWarnings()` (`ThemeBuilderEdits.php:117-160`) already use it (Q21).
|
||||
|
||||
**Scenario**
|
||||
1. A TB user may edit layout L (`edit_post` on L) but not template T. T is hidden from them by Q8/B11/Q18: tb-list omits it, and tb-assign/tb-activate answer "gibt es nicht".
|
||||
2. Any Divi write on L without `confirm` answers `confirm_required` with `templates: [{id: T, title, use_on: [...]}]`.
|
||||
3. That discloses T's ID, title and conditions, including object IDs such as `singular:post_type:page:id:<draft ID>`.
|
||||
4. In practice this is narrow: the account already holds `edit_others_posts` and the TB rights, so `edit_post` on a published template fails only under a capability plugin that restricts it (per-post or per-type rules). The class of leak is the one Q21 closed elsewhere.
|
||||
|
||||
**Evidence:** by reading (the code paths above). No second account exists on DDEV.
|
||||
|
||||
**Fix** (the decision stays on *all* live templates; only the answer is filtered):
|
||||
```php
|
||||
// ThemeBuilderEdits
|
||||
public function prepare(DiviPage $page, array $input): array|WP_Error
|
||||
{
|
||||
$templates = $this->showing($page); // all live ones – decides whether confirm is needed
|
||||
$details = $this->templateDetails($templates);
|
||||
if ($templates === []) { … unchanged … }
|
||||
… message keeps self::count($templates) (the total) …
|
||||
}
|
||||
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
… VisibleEdits::merged($error, …self::count($templates)…, $this->templateDetails($templates)) …
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q21/Q22: live templates the user may edit by id/title/use_on, the others only as a count.
|
||||
* @param list<Template> $templates
|
||||
* @return array{templates: list<array{id: int, title: string, use_on: list<string>}>, hidden_templates: int}
|
||||
*/
|
||||
private function templateDetails(array $templates): array
|
||||
{
|
||||
$shown = array_values(array_filter(
|
||||
$templates,
|
||||
fn (Template $t): bool => $this->access->canEditTemplate($t->id)
|
||||
));
|
||||
|
||||
return ['templates' => self::listing($shown), 'hidden_templates' => count($templates) - count($shown)];
|
||||
}
|
||||
```
|
||||
- `DiviReplaceTextAbility::refused()` (`:402-408`): add `'hidden_templates' => $data['hidden_templates'] ?? 0`, and keep `isset($data['templates'])` as the discriminator. It stays set, possibly `[]`.
|
||||
- `DiviWriteAbility::outputSchema()` (`DiviWriteAbility.php:45`): add `'hidden_templates' => ['type' => 'integer']`.
|
||||
- Optionally append `Template::hiddenOverlapsNote`-style wording to the message: "… davon %d ohne Berechtigung".
|
||||
- Tests:
|
||||
- A layout shown only by an uneditable live template: still `confirm_required`, `templates: []`, `hidden_templates: 1`.
|
||||
- The confirmed save still succeeds.
|
||||
- The replace-text refusal carries no uneditable ID.
|
||||
|
||||
### L3 – Low (rollback): TB layout (and library entry) revisions cannot be restored through MCP, while the docs and results present the revision as the way back
|
||||
|
||||
**Where**
|
||||
- `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php:152-162` and `RevisionsListAbility.php:104-111`: `PostGuard::editable()` with `ContentSchema`. `ContentSchema::EXCLUDED` (`ContentSchema.php:19-22`) contains `et_header_layout`, `et_body_layout`, `et_footer_layout`, `et_template` and `et_pb_layout`, so both tools answer "nicht gefunden".
|
||||
- `docs/guides/theme-builder.md:17` and `:100-101` ("Layouts erhalten wie Divi-Seiten eine Revision").
|
||||
- The write result carries `revision_id` (`DiviWriter::save()`).
|
||||
- Live: `wp_revisions_enabled()` is true for 1061/1062/1063/2391, so the revision exists. It is reachable only through `wp-admin/revision.php?revision=<id>` (no UI link, because the TB types have `show_ui` off in the admin menu).
|
||||
|
||||
**Scenario**
|
||||
1. A confirmed edit changes the live site header (dry run, confirm and revision as designed, Q2/Q9).
|
||||
2. The user asks the assistant to undo it.
|
||||
3. The assistant, following the guide, calls `lit/revisions-list` / `lit/revision-restore` for the layout and gets "nicht gefunden".
|
||||
4. No MCP path remains to return the visible site to its previous state. The same gap exists for global library entries (3a; not recorded there).
|
||||
5. The restore point exists (the backup guarantee holds). Its use through the tool the plugin itself points to fails, so a bad visible change stays visible longer than the design assumes.
|
||||
|
||||
**Evidence:** by reading, plus the live `wp_revisions_enabled` probe.
|
||||
|
||||
**Fix** (either one):
|
||||
- **Minimal:** add `admin_url('revision.php?revision=' . $revisionId)` as a `restore_url` to the result of a TB layout and library write (`DiviWriter::savePrepared()`, and `save()` for these scopes). Fix both guide passages: "Revision im WordPress-Revisionsvergleich wiederherstellen (restore_url); lit/revision-restore gilt nur für Seiten und Beiträge".
|
||||
- **Complete (Etappe U):** let `lit/revision-restore` accept revisions of TB layouts and library entries through `DiviPageLoader::load(…, true)` (ThemeBuilderAccess/LibraryAccess rights and group gates). Treat the restore as a visible edit: route it through `ThemeBuilderEdits::prepare()` / `GlobalEdits::prepare()` for the dry run, confirm and P8a.
|
||||
|
||||
### L4 – Low (integrity): `lit/tb-assign reattach` puts any published template on the live list that the live list does not name, including templates owned by a Divi Theme Builder library post
|
||||
|
||||
**Where**
|
||||
- `src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php:134` and `:148-181`: "detached" means only "not in the live list" (`Template::$attached`, `ThemeBuilderAccess::checkedTemplate()` `:329`).
|
||||
- `DiviThemeBuilder::detachedIds()` (`DiviThemeBuilder.php:147-167`), the source tb-list shows as `detached`, excludes templates listed by *any* `et_theme_builder` post: live, draft, and library TB posts marked `_et_library_theme_builder` (Divi `local-library.php:407-427`).
|
||||
|
||||
**Scenario**
|
||||
1. A TB user (full TB rights) calls `lit/tb-assign` with `reattach: true` and the ID of a template that belongs to a Theme Builder library item. The ID is not shown by tb-list, but it is an `et_template` they can `edit_post`.
|
||||
2. The template is attached to the live list (disabled). Later tb-assign/tb-activate calls change the library item's own conditions and enabled flag, because it is the same post.
|
||||
3. Nothing becomes visible without the confirmed tb-activate, and the account could do the same in Divi's UI. So this is data integrity, not escalation.
|
||||
4. It contradicts the tool's own contract: "only templates a later Theme Builder save removed" (B19).
|
||||
|
||||
**Evidence:** by reading. DDEV has no library TB post (`et_theme_builder` 1064 only).
|
||||
|
||||
**Fix:** in `reattachRefusal()`, when `$reattach` is set, refuse unless no `et_theme_builder` post lists the template. Give the same answer as a not-found (P12 is not needed; the user has TB rights, but reuse the message "Vorlage %d ist keine entfernte Vorlage (siehe detached in lit/tb-list)"). Add `DiviThemeBuilder::isListedAnywhere(int $templateId): bool` (one `SELECT 1 FROM postmeta WHERE meta_key = '_et_template' AND meta_value = %s LIMIT 1`), which is the criterion `detachedIds()` uses. Test: a template listed only by a second `et_theme_builder` post is refused for reattach.
|
||||
|
||||
---
|
||||
|
||||
## Info / hardening
|
||||
|
||||
### I1 – `{{field:<key>}}` in a Theme Builder body publishes the field of every post the template covers, including fields hidden for the author of the change
|
||||
|
||||
- `DynamicContentRules::fieldsFor()` (`DynamicContentRules.php:102-111`) accepts every non-protected `custom_meta_<key>`. It checks neither the Divi role option `read_dynamic_content_custom_fields` (Divi counts it "dangerous": `core.php:1138`) nor the field's `FieldDefinition::HIDDEN` status for the user.
|
||||
- `DynamicFields::offered()` (`DynamicFields.php:54`) skips hidden fields. So `ThemeBuilderEdits::fieldWarnings()` reports a hidden field as "kein bekanntes Feld dieses Inhaltstyps" (`:90`) and saves anyway (E12, warnings only).
|
||||
- Once the template is live, visitors see that field's value for every post of the type.
|
||||
- Preflight ruled display as parity: Divi gates only the edit context, and Divi's manual meta-key entry (`DynamicContentOptionPostMetaKey.php:307`, `! $is_manual_entry`) works without the option.
|
||||
- TB rights imply `edit_theme_options` (normally administrators), so this is recorded, not a finding.
|
||||
- Cheap hardening: in `ThemeBuilderEdits::checkKeys()` and `TbCreateTemplateAbility` (body), refuse a key that is `HIDDEN` for the user in any post type of the template's conditions, instead of warning. That extends the "hidden fields are neither offered nor named" rule (2b M4) to "nor published".
|
||||
|
||||
### I2 – Disabling the `lit/tb-*` tools one by one does not close Theme Builder layouts to the Divi tools
|
||||
|
||||
- `Plugin.php:100-105` wires `groupEnabled()`/`groupWritable()` (`ExposurePolicy.php:185-197`), which read only the group mode and the "nur lesen" switch, not per-tool overrides.
|
||||
- An administrator who turns off all four TB tools individually (instead of the group) still lets `lit-divi-update` & co. edit live TB layouts (with dry run and confirm).
|
||||
- This is the same model as the library group (only `lit/library-insert` has a cross-tool gate, audit 3a L5).
|
||||
- Hardening: make the gate `groupEnabled(GROUP) && allows('lit/tb-list', …)`, as done for `libraryInsertable`. Or state in the Tools tab that the group switch, not the tool switches, governs TB layouts.
|
||||
|
||||
### I3 – Confirm of live TB changes is not bound to the dry run
|
||||
|
||||
- `ThemeBuilderEdits::prepare()`, `TbAssignAbility::dryRun()` and `TbActivateAbility::dryRun()` recompute everything at confirm (rights, `editableLiveTemplates()`, `template_rev`/`tree_rev`). So rights are safe.
|
||||
- The set of templates or overlaps the user agreed to can still grow between the dry run and the confirm. This is the same class as 3a I2 and the deferred tree_rev race.
|
||||
- Optional: accept an `expected_templates` hash.
|
||||
|
||||
---
|
||||
|
||||
## Deferred items re-checked (no change of severity)
|
||||
|
||||
- **Q13 replace-text prefilter/next_cursor oracle:** the prefilter uses `PostGuard::postType(…, $schema)`, i.e. managed types only (`DiviReplaceTextAbility.php:133`). TB and library types are never searched, so it gives no new TB oracle. Explicit TB targets go through ThemeBuilderAccess. Stays U.
|
||||
- **Attach race** (`DiviThemeBuilder::attach()` vs a concurrent Divi TB save): at worst the new template is dropped from the list and marked unused. It is disabled, so nothing becomes visible. Stays U.
|
||||
- **try/catch around Divi read calls:** a throw in `et_theme_builder_get_template()` ends the request before any write. Every TB write is a small meta sequence with explicit undo. Stays U.
|
||||
- **tree_rev race:** unchanged class, and TB layouts inherit it. Stays U.
|
||||
- **DNS pinning, preset gates:** not reachable from the TB code. Stay U and 4.
|
||||
|
||||
---
|
||||
|
||||
## Areas checked and found sound
|
||||
|
||||
- **Rights on every new write path:**
|
||||
|
||||
| Path | Checks |
|
||||
|---|---|
|
||||
| `lit/tb-create-template` | `canCreate()` = `canUse()` (`edit_others_posts`, `edit_theme_options`, Divi `theme_builder`), group gate, `publish_posts` of all four types, draft refusal. `permission()` and `execute()` both run it. |
|
||||
| `lit/tb-assign` / `lit/tb-activate` | `changeableTemplate()` = `template()` (published `et_template`, `canEditTemplate` = `visible()` + `edit_post`, else not found – Q18), group gate, draft refusal (except the off-switch – Q20), not default, `template_rev`. |
|
||||
| Off-switch for trashed live templates (Q16) | Raw lookup requires live list, enabled and `canEditTemplate`. Every other unpublished ID reads as missing. |
|
||||
| TB layouts in the Divi tools | `DiviPageLoader::load()` → `ThemeBuilderAccess::layout()`: not visible → `PostGuard::notFound` (the same text as a missing ID or an unmanaged type); `edit_post`; for writes the group gate and `publish`. TB types stay out of `ContentSchema`, so generic content, status, meta, revision and duplicate tools cannot reach them. |
|
||||
| `lit/library-make-global` | Sources and targets only `page`/`post` (Q24/Q25). Library right `add_library`, plus `canSave(true)` for a new entry. Every target through `DiviWriter::page()` (`edit_post`, `tree_rev`) and `StoredNodeGuard`. Live targets get the VisibleEdits backup refusal before any write. |
|
||||
| `lit/library-find-similar` | `canList()`, `edit_post` per scanned post, TB and library types never scanned, own-posts restriction. The reference entry goes through `LibraryAccess::item()`. |
|
||||
|
||||
- **kses window:**
|
||||
- TB layout content reaches `DiviSaver` only through `DiviWriter::commit()` → `commitLayout()`: `ThemeBuilderEdits` first, then `save()`. The alternative is `saveNew()` after `LayoutBuilder` (Composer + TreeEditor, the D7 choke point) in tb-create-template.
|
||||
- Titles go through `sanitize_text_field` and core kses, since Divi's `insert_layout`/`store_template` run outside the window.
|
||||
- Conditions are regex-limited (`^[A-Za-z0-9_:\-]{1,200}$`) and taken only from Divi's flat list.
|
||||
- Multisite site admins (TB rights, no `unfiltered_html`) get the guard's strict branch. The backup is refused (P9) rather than made lossy.
|
||||
- **Area rule (F22):** `StructureRules::areaViolation()` and `nestedAreaViolation()` run in `TreeEditor::insert()` for composed and stored nodes (copy, library insert, duplicate) alike.
|
||||
- **"Live" semantics:** `Template::isLive()` (attached and enabled, any status) and `showsLayout()` (area enabled) match `ThemeBuilderRequest::get_template()`: enabled only; default template only if enabled. Checked live: the default template 1065 has `enabled: false`, so its header 1061 is not live, and the plugin agrees. `showing()` decides confirm on all live templates. With L2's fix it must keep doing so.
|
||||
- **Rollback:**
|
||||
- `TemplateBuild` records layouts, a Divi-created live TB post (B12) and the template, including posts inserted before a Divi failure (`InsertedBeforeFailure`, Q19). It undoes detach first and deletes newest first with `layout_type` terms. Partial results name only the leftover IDs.
|
||||
- tb-assign restores conditions, detach and enabled. tb-activate restores the flag.
|
||||
- `DiviSaver::restore()` writes back the previous content.
|
||||
- VisibleEdits backups are drafts moved to the trash. Divi's `et_theme_builder_trash_draft_and_unused_posts()` queries default (non-trash) statuses and never touches them.
|
||||
- **wp_die guard:** the right is checked before Divi; all six `wp_die_*` handler filters are replaced by a throwing closure, removed in `finally` together with the output buffers.
|
||||
- **Conditions (TemplateConditions):**
|
||||
- Posts need the exact type, a living status and `read_post`.
|
||||
- Terms need the right taxonomy and `assign_term`.
|
||||
- Users need `list_users`. Roles must exist.
|
||||
- Other object types are refused.
|
||||
- Missing and unreadable objects get the same message, and labels name objects by stored ID only.
|
||||
- Divi's flat list holds 34 IDs without post titles (live).
|
||||
- **Oracles:**
|
||||
- Every TB ID (layout or template) the user may not use answers like a missing one.
|
||||
- tb-list shows uneditable templates only as the `hidden_live` count, and layout titles only with `edit_post`.
|
||||
- `shared_with` lists only visible templates.
|
||||
- tb-create's `global` source gives the same answer for a missing and an uneditable default layout.
|
||||
- `{{link:}}`/`post_link_url_*` refuse TB and library types uniformly (Q14: not in `et_builder_get_public_post_types()`).
|
||||
- Remaining gaps: L1 (library dry run) and L2.
|
||||
- **Dynamic content and Pods:**
|
||||
- `post_link_url_<type>` requires the target's exact type (P10, `PostIdParser` without saturation).
|
||||
- `DynamicFields::metaKeyOf()` only reads (both Divi forms, Q11); writing stays on `VariableRef::canonical()` (S29/S30).
|
||||
- Pods table storage is detected with a `try`.
|
||||
- Hidden fields are never named. Field warnings name only editable templates and count the rest.
|
||||
- **make-global identity (Q25):** `Fingerprint::canonical()` includes text parts and opaque bytes. The instance is `GlobalInstance::of(<entry root>)`. `UsageMatcher::referencesAny()` fails closed on any `"globalModule"` key and on D4 `global_module` shortcodes.
|
||||
- **Disclosure elsewhere:** outline, node-get, text search and render summary hide library IDs without library access (P13). SimilarSections only warns while `revealsIds()` holds, and only with entries the user may `edit_post`.
|
||||
- **DoS:**
|
||||
- find-similar is capped (200 posts, 200 matches, 5000 nodes, chunks of 50).
|
||||
- make-global allows at most 20 targets × 20 IDs.
|
||||
- Conditions are capped at 50.
|
||||
- `detachedIds` is limited to 50.
|
||||
- The TB write clears all static CSS, which the same account can do in Divi anyway.
|
||||
|
||||
## Status nach der Fix-Welle (2026-09-27)
|
||||
- L1 (Q17 M1): behoben in 43dc3fb – GlobalUsages nutzt den Theme-Builder-Zugriff; ohne Sicht nur Zählung, weiterhin blockierend.
|
||||
- L2 (Q22): behoben in 7590da4 – nur bearbeitbare Live-Vorlagen werden genannt, andere als hidden_templates; confirm entscheidet weiter über alle.
|
||||
- L3: minimal behoben in dcae2d4 und 771f379 – restore_url zum WordPress-Revisionsbildschirm, auch im Fehlerfall; Wiederherstellen über MCP folgt in Etappe U.
|
||||
- L4: behoben in e64d32b – reattach nur für Vorlagen, die kein Theme-Builder-Beitrag listet.
|
||||
- I1, I3: bewusst belassen (siehe Rulings); I2: in der Theme-Builder-Anleitung dokumentiert (50cfa42).
|
||||
- Scoped Re-Review: Approved; 2202 Tests grün, tb- und divi-library-Smoke grün.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,7 @@ Ob dieser Aufbau stabil funktioniert, entscheidet Etappe 0 (§12).
|
||||
|
||||
- **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set:
|
||||
`lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`.
|
||||
- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). **Stand nach Etappe 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“.
|
||||
- **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“. **Stand nach Etappe 3b:** weiterhin 22 direkte Tools; indirekt neu `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` (neue Tool-Gruppe „Divi Theme Builder“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Theme-Builder-Layouts, deaktiviert gelten Vorlagen und Layouts als nicht vorhanden) sowie `lit/library-find-similar`, `lit/library-make-global` (Gruppe „Divi-Bibliothek“).
|
||||
- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`).
|
||||
- In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind.
|
||||
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
|
||||
@@ -159,7 +159,7 @@ tests/Unit, tests/Integration, tests/Fixtures
|
||||
|
||||
1. **Verbinden:** mcp-oauth-Autorisierung (jeder eingeloggte Benutzer).
|
||||
2. **Nutzen:** Capability `lit_mcp_use` (nur freigegebene Rollen) – geprüft über Adapter-Filter und in jeder Ability.
|
||||
3. **Handeln:** konkrete Prüfung pro Ability, z. B. `edit_post` für die Ziel-ID, `publish_post` fürs Veröffentlichen, `upload_files`, `edit_theme_options` für Divi-Designsystem und Menüs, `manage_options` für WP-Einstellungen, `edit_others_posts` für Theme Builder (Divi-Anforderung).
|
||||
3. **Handeln:** konkrete Prüfung pro Ability, z. B. `edit_post` für die Ziel-ID, `publish_post` fürs Veröffentlichen, `upload_files`, `edit_theme_options` für Divi-Designsystem und Menüs, `manage_options` für WP-Einstellungen, `edit_others_posts`, `edit_theme_options` und Divis Rollen-Recht „Theme Builder“ für den Theme Builder (Divi-Anforderung und Gate von Divis Theme-Builder-Seite; Etappe 3b), dazu `edit_post` je Vorlage/Layout; Vorlagen ohne `edit_post` antworten wie fehlende IDs, aktive davon erscheinen nur als Anzahl.
|
||||
|
||||
## 5. Divi-Kern
|
||||
|
||||
@@ -229,7 +229,7 @@ Jede Schreib-Rückmeldung enthält Warnungen zu:
|
||||
- leeren Modulen,
|
||||
- Sprüngen in der Überschriftenhierarchie,
|
||||
- Verwendung des Code-Moduls,
|
||||
- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`) – ab Etappe 3 zusammen mit `lit/library-find-similar` (D6).
|
||||
- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit-library-insert` und `lit/library-find-similar`) – verglichen wird nur ein neu eingefügter Abschnitt mit den Abschnitten der Divi-Bibliothek (Struktur-Fingerabdruck, höchstens 200 Einträge, je Anfrage einmal gelesen), nur mit Bibliothekszugang (Gruppe nicht deaktiviert, Recht „Divi-Bibliothek“; P13) und nicht bei `lit/library-insert` selbst (Etappe 3b, E11).
|
||||
|
||||
Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nicht angegeben.
|
||||
|
||||
@@ -238,7 +238,7 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
|
||||
1. Rechte prüfen (`edit_post`), `tree_rev` prüfen.
|
||||
2. Änderung auf dem BlockTree ausführen, validieren (alles oder nichts).
|
||||
3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, vorher Wiederherstellungspunkt (Revision). Für Benutzer ohne `unfiltered_html` wird kses für genau diesen Aufruf abgeschaltet: `filter_block_content()` würde sonst jeden unveränderten Block neu serialisieren (Byte-Identität, globale Module; in DDEV nachgewiesen). Das ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt (D7): Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung gilt unabhängig davon (Verteidigung in der Tiefe, R2).
|
||||
4. Post-Meta `_et_pb_use_divi_5=on`, `_et_pb_use_builder=on` setzen.
|
||||
4. Post-Meta `_et_pb_use_divi_5=on`, `_et_pb_use_builder=on` setzen (Seiten und Theme-Builder-Layouts; Bibliothekseinträge nicht).
|
||||
5. `ET_Core_PageResource::remove_static_resources($post_id, 'all', true)`; bei globalen Library-Elementen und Theme-Builder-Änderungen `('all', 'all', true)` sowie `et_theme_builder_clear_wp_cache('all')`.
|
||||
6. Protokolleintrag, `ToolResult` zurückgeben.
|
||||
|
||||
@@ -262,11 +262,12 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
|
||||
| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global; global braucht Divis „Globale Bibliothekselemente bearbeiten“, P7); Knoten auf der Seite bleibt unverändert; StoredNodeGuard (E6) |
|
||||
| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt |
|
||||
| `lit/divi-node-copy` | schreiben | Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, `source_tree_rev` Pflicht, S7/S26-Prüfung E6) |
|
||||
| `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln; optional gleichartige Vorkommen ersetzen |
|
||||
| `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) |
|
||||
| `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts |
|
||||
| `lit/tb-create-template` | schreiben | Vorlage mit Header/Body/Footer-Layouts anlegen (`et_theme_builder_insert_layout`, `et_theme_builder_store_template`, Meta `_et_template`) |
|
||||
| `lit/tb-assign` | schreiben | Bedingungen (`_et_use_on`/`_et_exclude_from`, z. B. `singular:post_type:person:all`) setzen |
|
||||
| `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln (alles oder nichts); optional gleichartige Vorkommen ersetzen: gleichartig = inhaltsgleich einschließlich aller Texte (Inhalts-Fingerabdruck und normalisierte Bytes), `library_id` + `targets`, Probelauf + `confirm`, P8a, Revision je Seite; Seite N scheitert → vorherige bleiben ersetzt, Fehler nennt geänderte/gescheiterte/unveränderte Seiten (E10). Nur Inhaltstypen `page` und `post` – keine Bibliothekseinträge, keine Theme-Builder-Layouts, auch nicht als Quelle; Knoten mit Verweis auf einen Eintrag (jede Schreibweise) werden kein globales Modul |
|
||||
| `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) und Inhalts-Fingerabdruck (`identical`); höchstens 200 Einträge je Aufruf, `cursor`, nur bearbeitbare Einträge, nie Bibliothekseinträge oder Theme-Builder-Layouts (E11) |
|
||||
| `lit/tb-list` | lesen | Vorlagen, Bedingungen (Divis Beschriftungen, Objekte nur als ID), Layouts (`shared_with`), `template_rev`; `detached` = von einem veralteten Theme-Builder-Fenster entfernte Vorlagen; optional Divis Bedingungskatalog; nicht bearbeitbare Vorlagen fehlen, aktive nur als Anzahl |
|
||||
| `lit/tb-create-template` | schreiben | Vorlage mit Header/Body/Footer-Layouts anlegen (`et_theme_builder_insert_layout`, `et_theme_builder_store_template`, Meta `_et_template`); immer deaktiviert (Antwort F1); je Bereich neu, global (Standardvorlage), leer oder ausgeblendet; alles oder nichts; abgelehnt, solange der Theme Builder einen Entwurf hat |
|
||||
| `lit/tb-assign` | schreiben | Bedingungen (`_et_use_on`/`_et_exclude_from`, z. B. `singular:post_type:person:all`) setzen – nur Divis eigene, Objekte lesbar, Autoren nur mit `list_users`; aktive Vorlage nur nach Probelauf und `confirm`; `reattach` für vom Theme Builder entfernte Vorlagen (bleiben deaktiviert) |
|
||||
| `lit/tb-activate` | schreiben | Vorlage aktivieren/deaktivieren – einzige Stelle, die eine Vorlage sichtbar macht; immer Probelauf, dann `confirm` (Antwort F1, Etappe 3b); Deaktivieren geht immer, auch bei Theme-Builder-Entwurf (mit Warnung) und im Papierkorb |
|
||||
|
||||
## 6. WordPress-Basis
|
||||
|
||||
@@ -403,6 +404,7 @@ Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zei
|
||||
- Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben,
|
||||
- Verweis auf `lit/guide`.
|
||||
- Etappe 3a: Zeile „Divi-Bibliothek: …“ (globale Module im Bibliothekseintrag ändern, Probelauf und `confirm: true`, Guide `divi-library`), nur wenn die Gruppe „Divi-Bibliothek“ aktiv und beschreibbar ist.
|
||||
- Etappe 3b: Zeile „Divi Theme Builder: …“ (neue Vorlagen deaktiviert, Aktivieren nur mit `lit/tb-activate`, Guide `theme-builder`), nur bei aktiver, beschreibbarer Gruppe „Divi Theme Builder“.
|
||||
|
||||
### 9.2 `lit/guide(topic)`
|
||||
|
||||
@@ -412,7 +414,7 @@ Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-plac
|
||||
### 9.3 MCP-Prompts
|
||||
|
||||
`corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`.
|
||||
(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)
|
||||
(Stand Etappe 3b: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`, `custom-post-type-vorlage` im Guide `theme-builder`.)
|
||||
|
||||
## 10. Sicherheit
|
||||
|
||||
@@ -466,7 +468,7 @@ Referenz: https://modelcontextprotocol.io/specification
|
||||
| **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein |
|
||||
| **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen |
|
||||
| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit 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 (3a Bibliothek, 3b Theme Builder und Konsolidierung) |
|
||||
| **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 | – |
|
||||
|
||||
@@ -491,6 +493,8 @@ Kann-Kriterien nach Etappe 4 oder bei freier Kapazität.
|
||||
| `.well-known`/POST auf `/wp-json/` durch Hoster/WAF blockiert | Site-Health-Test, Fehlersuche-Doku |
|
||||
| Design-Qualität neuer Abschnitte ohne Vorlagen | Library-first-Arbeitsweise, Designsystem-Platzhalter, Guides, Warnungen |
|
||||
| Parallele Bearbeitung im Builder | `tree_rev`-Sperre |
|
||||
| Veraltetes Theme-Builder-Fenster speichert und entfernt neue Vorlagen | Ablehnung bei Theme-Builder-Entwurf, `detached` in `lit/tb-list`, `reattach` (deaktiviert); Warnung in jeder Anlege-Antwort (Etappe 3b) |
|
||||
| Ungewöhnliche Schreibweisen globaler Verweise (P25), z. B. ein maskierter `divi/global-layout`-Block in einem Modulwert, den Divi in Shortcode-Modulen und WooCommerce-Textmodulen erneut auswertet | bekanntes Restrisiko, dokumentiert im Guide `divi-library`; braucht Bearbeitungsrechte auf der verweisenden Seite; `UsageMatcher` erkennt die übrigen Schreibweisen fail-closed |
|
||||
| Datenformate Designsystem nur aus Code abgeleitet (lokal keine Daten) | vor Etappe 4 echte Daten im Builder anlegen und als Fixtures sichern |
|
||||
|
||||
## 15. Offene Punkte (in Etappen zu klären)
|
||||
|
||||
+45
-6
@@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth
|
||||
Requires at least: 6.9
|
||||
Tested up to: 7.1
|
||||
Requires PHP: 8.1
|
||||
Stable tag: 0.4.0
|
||||
Stable tag: 0.5.0
|
||||
License: GPL-2.0-or-later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
@@ -14,8 +14,9 @@ MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten.
|
||||
|
||||
WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (ein MCP-fähiger Client mit OAuth) diese
|
||||
Website pflegt: Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
|
||||
WordPress-Einstellungen, die Mediathek sowie Divi-Seiten und die Divi-Bibliothek (auch globale Module). Jede Änderung läuft mit den Rechten des verbundenen
|
||||
WordPress-Benutzers und wird protokolliert.
|
||||
WordPress-Einstellungen, die Mediathek sowie Divi-Seiten, die Divi-Bibliothek (auch globale Module) und den
|
||||
Divi Theme Builder. Jede Änderung läuft mit den Rechten des verbundenen WordPress-Benutzers und wird
|
||||
protokolliert.
|
||||
|
||||
Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).
|
||||
|
||||
@@ -72,11 +73,27 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
|
||||
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.
|
||||
privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf – bei Theme-Builder-Layouts
|
||||
auch ohne die Theme-Builder-Rechte; ohne Zugang zum Theme Builder nennt LIT MCP solche Layouts nur als Anzahl.
|
||||
* 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.
|
||||
* Divi Theme Builder: neue Vorlagen legt LIT MCP deaktiviert an (über Divis eigene Funktionen, mit Layouts für
|
||||
Kopfzeile, Inhalt und Fußzeile); sichtbar werden sie nur über lit/tb-activate nach Probelauf und confirm.
|
||||
Änderungen an Bedingungen aktiver Vorlagen und an Layouts, die Besucher sehen, verlangen ebenfalls Probelauf
|
||||
und confirm (Layouts vorher mit Revision bzw. Sicherungskopie). Deaktivieren geht immer. Der Theme Builder
|
||||
verlangt „Beiträge anderer bearbeiten“, „Design bearbeiten“ (edit_theme_options) und das Divi-Recht
|
||||
„Theme Builder“; Vorlagen, die der Benutzer nicht bearbeiten darf, nennt LIT MCP nicht (aktive nur als
|
||||
Anzahl). Solange der Theme Builder ungespeicherte Änderungen hat, ändert LIT MCP keine Vorlagen (außer
|
||||
Deaktivieren). Speichert ein Theme-Builder-Fenster, das vor dem Anlegen geöffnet wurde, entfernt Divi die neue
|
||||
Vorlage wieder; lit/tb-list zeigt sie dann unter detached.
|
||||
* lit/library-make-global legt globale Einträge an und ersetzt inhaltsgleiche Vorkommen auf Seiten und
|
||||
Beiträgen erst nach Probelauf und confirm, jede Seite mit eigener Revision; Theme-Builder-Layouts und
|
||||
Bibliothekseinträge ändert es nicht.
|
||||
* Ohne Zugang zur Divi-Bibliothek (Recht „Divi-Bibliothek“ oder Tools-Gruppe deaktiviert) nennt LIT MCP keine
|
||||
Bibliotheks-IDs, auch nicht bei globalen Modulen auf Seiten.
|
||||
* 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).
|
||||
Dieselben Zielregeln gelten für die Vollform post_link_url_<Typ>; Bibliothekseinträge sind kein Linkziel.
|
||||
* 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
|
||||
@@ -108,8 +125,14 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
|
||||
* 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.
|
||||
* 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.
|
||||
eine Sicherungskopie im Papierkorb (backup_id); zurück über den Revisionsvergleich in WordPress (restore_url
|
||||
in der Antwort; lit/revision-restore gilt nur für Inhaltstypen, nicht für Bibliothekseinträge). Neu
|
||||
angelegte Einträge (lit/library-save, lit/library-make-global) in der Divi-Bibliothek löschen; durch
|
||||
Instanzen ersetzte Knoten über die Revision der jeweiligen Seite zurückholen.
|
||||
* Theme Builder: Vorlagen haben keine Revisionen. lit/tb-assign nennt die bisherigen Bedingungen (previous) und
|
||||
schreibt sie ins Protokoll; lit/tb-activate schaltet zurück. Layouts erhalten wie Divi-Seiten eine Revision,
|
||||
wiederherstellbar im Revisionsvergleich von WordPress (restore_url in der Antwort; nicht über
|
||||
lit/revision-restore). Neu angelegte Vorlagen und Layouts im Divi Theme Builder löschen.
|
||||
* 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) –
|
||||
von dort in WordPress wiederherstellbar.
|
||||
@@ -169,6 +192,22 @@ Bibliothekseintrags.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 0.5.0 =
|
||||
* Divi Theme Builder: Vorlagen, Bedingungen und Layouts auflisten (lit/tb-list), Vorlagen mit Kopfzeile,
|
||||
Inhalt und Fußzeile anlegen – immer deaktiviert (lit/tb-create-template) –, Bedingungen setzen
|
||||
(lit/tb-assign, auch vom Theme Builder entfernte Vorlagen wieder zuordnen), ausdrücklich aktivieren oder
|
||||
deaktivieren (lit/tb-activate).
|
||||
* Die Divi-Werkzeuge bearbeiten Theme-Builder-Layouts; sichtbare Layouts erst nach Probelauf und confirm.
|
||||
Felder im Inhalt-Layout ({{field:…}} und im Divi-Builder gewählte Felder) werden gegen den Inhaltstyp der
|
||||
Vorlage geprüft (nur Warnungen).
|
||||
* Ähnliche Abschnitte finden (lit/library-find-similar); Knoten in globale Module umwandeln und
|
||||
inhaltsgleiche Vorkommen ersetzen (lit/library-make-global); Divi-Änderungen weisen auf gleich aufgebaute
|
||||
Abschnitte der Bibliothek hin.
|
||||
* Tools-Gruppe „Divi Theme Builder“; lit-site-status prüft die genutzten Theme-Builder-Funktionen.
|
||||
* Sicherheit: Bibliotheks-IDs bleiben ohne Bibliothekszugang verborgen; interne Links in der Vollform
|
||||
(post_link_url_*) folgen denselben Zielregeln wie {{link:ID}}; globale Einträge enthalten keine Verweise auf
|
||||
andere Einträge, in keiner Schreibweise.
|
||||
|
||||
= 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).
|
||||
|
||||
@@ -19,6 +19,9 @@ final class InstructionsBuilder
|
||||
/** Tool group of the Divi Library (LibraryAccess::GROUP); its rule needs a writable group. */
|
||||
private const LIBRARY_GROUP = 'divi-library';
|
||||
|
||||
/** Tool group of the Divi Theme Builder (ThemeBuilderAccess::GROUP); its rule needs a writable group. */
|
||||
private const THEME_BUILDER_GROUP = 'divi-theme-builder';
|
||||
|
||||
/** @var (callable(): list<string>)|null */
|
||||
private $postTypeNames;
|
||||
|
||||
@@ -75,6 +78,15 @@ final class InstructionsBuilder
|
||||
]
|
||||
: [];
|
||||
|
||||
$themeBuilder = str_contains(', ' . $active . ',', ', ' . self::THEME_BUILDER_GROUP . ',')
|
||||
&& $this->policy->groupWritable(self::THEME_BUILDER_GROUP)
|
||||
? [
|
||||
'- Divi Theme Builder: neue Vorlagen entstehen deaktiviert; aktivieren nur auf ausdrücklichen '
|
||||
. 'Wunsch mit lit/tb-activate (Probelauf, dann confirm: true); '
|
||||
. 'Ablauf: lit-guide topic "theme-builder".',
|
||||
]
|
||||
: [];
|
||||
|
||||
return implode("\n", array_merge($lines, [
|
||||
'Regeln:',
|
||||
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
|
||||
@@ -86,6 +98,6 @@ final class InstructionsBuilder
|
||||
'- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).',
|
||||
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
|
||||
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
|
||||
], $divi, $library));
|
||||
], $divi, $library, $themeBuilder));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||
|
||||
use stdClass;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and
|
||||
* lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two
|
||||
* sections built alike with other texts share it. content(): every attribute but the admin label,
|
||||
* builderVersion and globalParent, children and every non-whitespace text part included – "the same section"
|
||||
* (ruling Q25: raw HTML between blocks and the inner HTML of core blocks are visible too). Whitespace between
|
||||
* blocks never counts; an opaque block counts with all its bytes (content) or by its effective type (structure).
|
||||
* Pure PHP.
|
||||
*/
|
||||
final class Fingerprint
|
||||
{
|
||||
/** Top-level attributes that say nothing about what visitors see. */
|
||||
private const IGNORED = ['builderVersion', 'globalParent'];
|
||||
|
||||
public static function structure(Block $block): string
|
||||
{
|
||||
return self::hash(self::shape($block));
|
||||
}
|
||||
|
||||
public static function content(Block $block): string
|
||||
{
|
||||
return self::hash(self::body($block));
|
||||
}
|
||||
|
||||
/**
|
||||
* The normalized bytes content() hashes (ruling Q25): block names, attributes with sorted keys (without admin
|
||||
* label, builderVersion, globalParent), every non-whitespace text part – raw HTML or shortcodes between blocks
|
||||
* and the inner HTML of core blocks – and opaque blocks with all their bytes. Two nodes with the same
|
||||
* canonical() look the same to visitors; lit/library-make-global compares these bytes, not only the hash.
|
||||
*/
|
||||
public static function canonical(Block $block): string
|
||||
{
|
||||
return self::body($block);
|
||||
}
|
||||
|
||||
private static function shape(Block $block): string
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return 'opaque:' . GlobalInstance::effectiveName($block);
|
||||
}
|
||||
|
||||
$presets = NodeSummary::presetIds($block);
|
||||
sort($presets);
|
||||
$children = array_map(self::shape(...), $block->children());
|
||||
|
||||
return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets)
|
||||
. '[' . implode(';', $children) . ']';
|
||||
}
|
||||
|
||||
private static function body(Block $block): string
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return 'opaque:' . self::text(Serializer::block($block));
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
foreach (self::IGNORED as $key) {
|
||||
unset($attrs[$key]);
|
||||
}
|
||||
if (is_array($attrs['module']['meta'] ?? null)) {
|
||||
unset($attrs['module']['meta']['adminLabel']);
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($block->isVoid() ? [] : $block->parts() as $part) {
|
||||
if ($part instanceof Block) {
|
||||
$parts[] = self::body($part);
|
||||
} elseif (trim($part) !== '') {
|
||||
$parts[] = 'text:' . Json::encode(self::text($part));
|
||||
}
|
||||
}
|
||||
|
||||
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $parts) . ']';
|
||||
}
|
||||
|
||||
/** Text as visitors get it: line breaks unified, whitespace at both ends dropped. */
|
||||
private static function text(string $text): string
|
||||
{
|
||||
return trim(str_replace("\r\n", "\n", $text));
|
||||
}
|
||||
|
||||
/** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */
|
||||
private static function sorted(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof stdClass) {
|
||||
$value = (array) $value;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return array_map(self::sorted(...), $value);
|
||||
}
|
||||
|
||||
private static function hash(string $value): string
|
||||
{
|
||||
return substr(hash('sha256', $value), 0, 16);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,11 @@ final class Outline
|
||||
|
||||
private bool $truncated = false;
|
||||
|
||||
/** @param array<string, string> $presetNames Preset ID => name. */
|
||||
public function __construct(private array $presetNames = [])
|
||||
/**
|
||||
* @param array<string, string> $presetNames Preset ID => name.
|
||||
* @param bool $globalIds Ruling P13: false leaves out the library IDs of global instances.
|
||||
*/
|
||||
public function __construct(private array $presetNames = [], private bool $globalIds = true)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -157,7 +160,7 @@ final class Outline
|
||||
'editable' => $editable,
|
||||
'depth' => $depth,
|
||||
'truncated' => false,
|
||||
'global_id' => $global['id'] ?? null,
|
||||
'global_id' => $this->globalIds ? ($global['id'] ?? null) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ final class DynamicContentRules
|
||||
|
||||
private const META_PREFIX = 'custom_meta_';
|
||||
|
||||
/** Divi's internal link options: post_link_url_<post type> (F27). */
|
||||
public const LINK_PREFIX = 'post_link_url_';
|
||||
|
||||
/**
|
||||
* Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just
|
||||
* the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286;
|
||||
@@ -135,8 +138,13 @@ final class DynamicContentRules
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed
|
||||
{
|
||||
public static function cleanSetting(
|
||||
string $key,
|
||||
mixed $value,
|
||||
string $fieldType,
|
||||
ValueSanitizer $sanitizer,
|
||||
string $option = ''
|
||||
): mixed {
|
||||
// Ruling S32/S32a: control characters go on the RAW value, before every check and the
|
||||
// sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag).
|
||||
if (is_string($value)) {
|
||||
@@ -149,7 +157,7 @@ final class DynamicContentRules
|
||||
|
||||
$clean = match ($fieldType) {
|
||||
'text', 'richtext', 'html', 'url' => self::textSetting($key, $value, $fieldType, $sanitizer),
|
||||
'select_post' => self::selectPostSetting($key, $value, $sanitizer),
|
||||
'select_post' => self::selectPostSetting($key, $value, $sanitizer, $option),
|
||||
'select', 'yes_no_button', 'number' => self::scalarSetting($key, $value, $sanitizer),
|
||||
// Ruling S37: map types come from a filter (untrusted) – unknown types are refused.
|
||||
default => throw new InvalidArgumentException(sprintf(
|
||||
@@ -238,19 +246,28 @@ final class DynamicContentRules
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself).
|
||||
* Returned as sent (int or digit string).
|
||||
* Ruling S37/P10: a numeric post id (PostIdParser). In an internal link post_link_url_<type> the
|
||||
* id must name a link target of exactly that type (LinkTargets rules, like {{link:}}); in any
|
||||
* other option a post the user may read. Returned as sent (int or digit string).
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private static function selectPostSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed
|
||||
{
|
||||
$digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1;
|
||||
$id = is_int($value) ? $value : ($digits ? (int) $value : 0);
|
||||
if ($id <= 0 || ! $sanitizer->canReadPost($id)) {
|
||||
private static function selectPostSetting(
|
||||
string $key,
|
||||
mixed $value,
|
||||
ValueSanitizer $sanitizer,
|
||||
string $option
|
||||
): mixed {
|
||||
$id = PostIdParser::parse($value);
|
||||
$linkType = str_starts_with($option, self::LINK_PREFIX) ? substr($option, strlen(self::LINK_PREFIX)) : null;
|
||||
$valid = $id !== null && ($linkType === null
|
||||
? $sanitizer->canReadPost($id)
|
||||
: $sanitizer->linkTargetType($id) === $linkType);
|
||||
if (! $valid) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.',
|
||||
$key
|
||||
'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet%s.',
|
||||
$key,
|
||||
$linkType === null ? '' : sprintf(' (Typ „%s“, nicht im Papierkorb, kein Anhang)', $linkType)
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -158,15 +158,14 @@ final class Placeholders
|
||||
* {{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.
|
||||
* that the value goes into a URL field and names a link target of that type (P10) 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)) {
|
||||
$id = PostIdParser::parse($name);
|
||||
if ($id === null) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).',
|
||||
$name
|
||||
@@ -181,7 +180,7 @@ final class Placeholders
|
||||
));
|
||||
}
|
||||
|
||||
return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]);
|
||||
return VariableRef::serialize('content', DynamicContentRules::LINK_PREFIX . $type, ['post_id' => $name]);
|
||||
}
|
||||
|
||||
private function field(string $name): string
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The one rule for post IDs a client or a stored reference names (ruling P10): {{link:<ID>}},
|
||||
* post_link_url_* settings.post_id and Theme Builder conditions. A positive int, or its plain
|
||||
* decimal digits without sign, padding or spaces; nothing beyond PHP_INT_MAX (FILTER_VALIDATE_INT
|
||||
* refuses it instead of saturating to another entry). Pure PHP.
|
||||
*/
|
||||
final class PostIdParser
|
||||
{
|
||||
public static function parse(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (! is_string($value) || preg_match('/^[1-9][0-9]{0,18}$/D', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = filter_var($value, FILTER_VALIDATE_INT);
|
||||
|
||||
return is_int($id) ? $id : null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -18,7 +19,8 @@ defined('ABSPATH') || exit;
|
||||
* rules limit the depth); parent module → its child modules (catalog). Columns only come with
|
||||
* their row; specialty sections, specialty columns (module.advanced.specialtyColumns or
|
||||
* row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not
|
||||
* rebuilt. Pure PHP.
|
||||
* rebuilt. F22: in Theme Builder layouts only modules Divi renders there (appearance.themeBuilderLayouts,
|
||||
* e.g. divi/post-content only in the body). Pure PHP.
|
||||
*/
|
||||
final class StructureRules
|
||||
{
|
||||
@@ -26,7 +28,8 @@ final class StructureRules
|
||||
private const ROWS = ['divi/row', 'divi/row-inner'];
|
||||
private const FULLWIDTH = 'fullwidth-module';
|
||||
|
||||
public function __construct(private ModuleCatalog $catalog)
|
||||
/** @param ?string $themeBuilderArea "header", "body" or "footer" when the entry is a Theme Builder layout. */
|
||||
public function __construct(private ModuleCatalog $catalog, private ?string $themeBuilderArea = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -37,6 +40,11 @@ final class StructureRules
|
||||
return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child);
|
||||
}
|
||||
|
||||
$area = $this->areaViolation($child);
|
||||
if ($area !== null) {
|
||||
return $area;
|
||||
}
|
||||
|
||||
if ($parent === null) {
|
||||
return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt';
|
||||
}
|
||||
@@ -116,4 +124,41 @@ final class StructureRules
|
||||
|
||||
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
|
||||
}
|
||||
|
||||
/**
|
||||
* F22 for stored nodes that arrive with their children (copy, library entry, duplicate): the first module
|
||||
* below $block that Divi does not render in this Theme Builder area; null for pages.
|
||||
*/
|
||||
public function nestedAreaViolation(Block $block): ?string
|
||||
{
|
||||
if ($this->themeBuilderArea === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
$violation = $this->areaViolation(GlobalInstance::effectiveName($child))
|
||||
?? $this->nestedAreaViolation($child);
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** F22: in a Theme Builder layout, a module Divi renders only in other areas (Divi's own render rule). */
|
||||
private function areaViolation(string $child): ?string
|
||||
{
|
||||
$areas = $this->catalog->themeBuilderAreas($child);
|
||||
if ($areas === null || $this->themeBuilderArea === null || in_array($this->themeBuilderArea, $areas, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s zeigt Divi im Bereich „%s“ nicht (nur %s)',
|
||||
$child,
|
||||
$this->themeBuilderArea,
|
||||
implode(', ', $areas)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ final class TreeEditor
|
||||
public function insert(array $position, array $blocks): ?string
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block))
|
||||
?? $this->rules->nestedAreaViolation($block);
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,14 @@ interface ValueSanitizer
|
||||
|
||||
/**
|
||||
* Whether the current user may read post $id: select_post settings of dynamic content
|
||||
* references (post_link_url_*) name a post whose link Divi renders (ruling S37).
|
||||
* references outside internal links (ruling S37); post_link_url_* use linkTargetType() (P10).
|
||||
*/
|
||||
public function canReadPost(int $id): bool;
|
||||
|
||||
/**
|
||||
* Ruling P10: the post type of entry $id when it is a link target – exists, lives (no trash,
|
||||
* auto-draft or revision/attachment status), is no attachment and is readable – like {{link:}}
|
||||
* (LinkTargets); else null.
|
||||
*/
|
||||
public function linkTargetType(int $id): ?string;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,20 @@ final class VariableRef
|
||||
return is_array($settings) ? $settings : [];
|
||||
}
|
||||
|
||||
/** The option name of $value when it is exactly one content reference ("custom_meta_role"); null otherwise. */
|
||||
public static function nameOf(string $value): ?string
|
||||
{
|
||||
try {
|
||||
$payload = self::payloadOf($value);
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $payload['value']['name'] ?? null;
|
||||
|
||||
return ($payload['type'] ?? null) === 'content' && is_string($name) ? $name : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|stdClass $settings */
|
||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
||||
{
|
||||
@@ -252,7 +266,7 @@ final class VariableRef
|
||||
*/
|
||||
private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array
|
||||
{
|
||||
if (! $url && str_starts_with($name, 'post_link_url_')) {
|
||||
if (! $url && str_starts_with($name, DynamicContentRules::LINK_PREFIX)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.',
|
||||
$name
|
||||
@@ -269,7 +283,7 @@ final class VariableRef
|
||||
if ($url && ($key === 'before' || $key === 'after') && $value !== '') {
|
||||
throw new InvalidArgumentException(sprintf('"%s" im Divi-Verweis: in URL-Feldern verboten.', $key));
|
||||
}
|
||||
$clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer);
|
||||
$clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer, $name);
|
||||
}
|
||||
|
||||
return $clean;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -58,12 +59,17 @@ final class DiviCompatibility
|
||||
'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'),
|
||||
// Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2).
|
||||
'Theme Builder: Funktionen et_theme_builder_* und et_()' =>
|
||||
static fn (): bool => (new DiviThemeBuilder())->missing() === [],
|
||||
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
|
||||
DiviThemeBuilder::postTypes(),
|
||||
static fn (string $type): bool => ! post_type_exists($type)
|
||||
) === [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ 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\Library\SimilarSections;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
|
||||
@@ -33,6 +35,10 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderIntegration;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -50,6 +56,7 @@ final class DiviIntegration implements Integration
|
||||
private DiviModules $modules;
|
||||
private DesignSystemReader $design;
|
||||
private LibraryAccess $access;
|
||||
private ThemeBuilderAccess $themeBuilderAccess;
|
||||
|
||||
/**
|
||||
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
|
||||
@@ -60,6 +67,10 @@ final class DiviIntegration implements Integration
|
||||
* 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.
|
||||
* @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in
|
||||
* the Divi tools (group "Divi Theme Builder" writable, E9); null = always.
|
||||
* @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi
|
||||
* tools at all (group "Divi Theme Builder" not disabled); null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
@@ -67,11 +78,20 @@ final class DiviIntegration implements Integration
|
||||
?Closure $libraryWritable = null,
|
||||
?Closure $libraryVisible = null,
|
||||
private ?Closure $diviWritable = null,
|
||||
private ?Closure $libraryInsertable = null
|
||||
private ?Closure $libraryInsertable = null,
|
||||
?Closure $themeBuilderWritable = null,
|
||||
?Closure $themeBuilderVisible = null
|
||||
) {
|
||||
$this->modules = new DiviModules();
|
||||
$this->design = new DesignSystemReader();
|
||||
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
|
||||
$roles = new DiviRoles();
|
||||
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
|
||||
$this->themeBuilderAccess = new ThemeBuilderAccess(
|
||||
new DiviThemeBuilder(),
|
||||
$roles,
|
||||
$themeBuilderWritable,
|
||||
$themeBuilderVisible
|
||||
);
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
@@ -79,6 +99,12 @@ final class DiviIntegration implements Integration
|
||||
return 'divi';
|
||||
}
|
||||
|
||||
/** Ruling P13: whether this connection may see library IDs (library group visible, library right). */
|
||||
public function libraryIdsVisible(): bool
|
||||
{
|
||||
return $this->access->revealsIds();
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __('Divi', 'wp-lit-mcp');
|
||||
@@ -143,7 +169,32 @@ final class DiviIntegration implements Integration
|
||||
new DiviSaver(),
|
||||
$this->log,
|
||||
$this->modules,
|
||||
$this->diviWritable
|
||||
$this->diviWritable,
|
||||
$this->schema,
|
||||
$this->globalUsages()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The usages of library entries with this integration's Theme Builder access (ruling Q17 M1, review I-2):
|
||||
* Theme Builder posts are named only while ThemeBuilderAccess::visible(), and a global edit that changes them
|
||||
* needs the rights of every Theme Builder write (canUse(), in visible()) and a writable "Divi Theme Builder"
|
||||
* group – as the Divi tools on those layouts.
|
||||
*/
|
||||
public function globalUsages(?\wpdb $db = null): GlobalUsages
|
||||
{
|
||||
return new GlobalUsages($db, $this->access->roles(), $this->themeBuilderAccess->usageState(...));
|
||||
}
|
||||
|
||||
/** The group "Divi Theme Builder" (lit/tb-*), sharing this integration's Theme Builder access (Etappe 3b). */
|
||||
public function themeBuilder(): ThemeBuilderIntegration
|
||||
{
|
||||
return new ThemeBuilderIntegration(
|
||||
$this->themeBuilderAccess,
|
||||
new DiviComposition($this->modules, $this->design),
|
||||
new DiviSaver(),
|
||||
$this->log,
|
||||
new DynamicFields($this->schema)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,12 +206,14 @@ final class DiviIntegration implements Integration
|
||||
$this->log,
|
||||
$this->design,
|
||||
new DiviComposition($this->modules, $this->design),
|
||||
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles()))
|
||||
new GlobalEdits($this->access, $this->globalUsages()),
|
||||
new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)),
|
||||
new SimilarSections($this->access)
|
||||
);
|
||||
}
|
||||
|
||||
private function loader(): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||||
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
@@ -27,25 +27,22 @@ defined('ABSPATH') || exit;
|
||||
* 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.
|
||||
* copy is made first – the revision itself is DiviSaver's restore point, as for every page
|
||||
* (VisibleEdits, shared with live Theme Builder layouts, ruling Q2).
|
||||
* 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).
|
||||
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37; VisibleEdits).
|
||||
*/
|
||||
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;
|
||||
private VisibleEdits $visible;
|
||||
|
||||
/**
|
||||
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
|
||||
@@ -58,9 +55,7 @@ final class GlobalEdits
|
||||
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;
|
||||
$this->visible = new VisibleEdits($backup, $trashEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,17 +103,21 @@ final class GlobalEdits
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item);
|
||||
$refusal = $this->pagesRefusal(self::label($id, $global), $report)
|
||||
?? $this->visible->backupRefusal(
|
||||
$item,
|
||||
sprintf('Bibliothekseintrag %d', $id),
|
||||
'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder '
|
||||
. 'bearbeiten.'
|
||||
);
|
||||
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];
|
||||
return [
|
||||
'scope' => SaveScope::GlobalItem,
|
||||
'details' => array_merge($report->listing(), $this->visible->backup($item)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,20 +136,15 @@ final class GlobalEdits
|
||||
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())
|
||||
return VisibleEdits::merged(
|
||||
$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),
|
||||
$report->details()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,39 +248,4 @@ final class GlobalEdits
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -16,8 +18,10 @@ defined('ABSPATH') || exit;
|
||||
* 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.
|
||||
* readable, else as a count; above HARD_CAP live posts the edit is refused as a whole;
|
||||
* - Theme Builder posts follow the Theme Builder access (ruling Q17 M1, review I-2, audit L1/L6): readable
|
||||
* only while the Theme Builder is visible to the caller (else counted, never named – P12, like
|
||||
* ThemeBuilderAccess), editable only while the caller may use it (canUse()) plus edit_post.
|
||||
* Not final so tests can mock rows().
|
||||
*/
|
||||
class GlobalUsages
|
||||
@@ -46,13 +50,23 @@ class GlobalUsages
|
||||
/** 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)
|
||||
{
|
||||
/** @var array{visible: bool, usable: bool}|null The Theme Builder access of the current report(). */
|
||||
private ?array $themeBuilderState = null;
|
||||
|
||||
/**
|
||||
* @param (Closure(): array{visible: bool, usable: bool})|null $themeBuilder The caller's Theme Builder access
|
||||
* (DiviIntegration wraps ThemeBuilderAccess – a closure keeps Library free of it): visible = TB objects exist
|
||||
* for the caller, usable = the caller may change them; asked at most once per report(). null = what
|
||||
* ThemeBuilderAccess::canUse() needs, for both (Divi's role option theme_builder, edit_others_posts,
|
||||
* edit_theme_options).
|
||||
*/
|
||||
public function __construct(
|
||||
private ?\wpdb $db = null,
|
||||
?DiviRoles $roles = null,
|
||||
private ?Closure $themeBuilder = null
|
||||
) {
|
||||
$this->roles = $roles ?? new DiviRoles();
|
||||
}
|
||||
|
||||
@@ -67,6 +81,7 @@ class GlobalUsages
|
||||
$tooMany = $withBlocked && $live > self::HARD_CAP;
|
||||
$checkEdit = $withBlocked && ! $tooMany;
|
||||
|
||||
$this->themeBuilderState = null;
|
||||
$items = [];
|
||||
$hidden = 0;
|
||||
$blocked = [];
|
||||
@@ -95,7 +110,7 @@ class GlobalUsages
|
||||
_prime_post_caches($ids, false, false);
|
||||
foreach ($todo as $row) {
|
||||
$id = $row['id'];
|
||||
$readable = current_user_can('read_post', $id);
|
||||
$readable = $this->readable($row);
|
||||
if ($readable && count($items) < self::LIMIT) {
|
||||
$post = get_post($id);
|
||||
$items[] = [
|
||||
@@ -209,21 +224,63 @@ class GlobalUsages
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Whether $row may be named to the caller: read_post; a Theme Builder post only while the Theme Builder is
|
||||
* visible to the caller (review I-2 B: otherwise it reads like a missing ID elsewhere, P12).
|
||||
*
|
||||
* @param array{id: int, status: string, type: string} $row
|
||||
*/
|
||||
private function readable(array $row): bool
|
||||
{
|
||||
if (self::isThemeBuilder($row) && ! $this->themeBuilder()['visible']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current_user_can('read_post', $row['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder post additionally needs the
|
||||
* Theme Builder rights (ruling Q17 M1 – as every Theme Builder gate, ThemeBuilderAccess::canUse(); audit L6).
|
||||
*
|
||||
* @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')) {
|
||||
if (self::isThemeBuilder($row) && ! $this->themeBuilder()['usable']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current_user_can('edit_post', $row['id']);
|
||||
}
|
||||
|
||||
/** @param array{id: int, status: string, type: string} $row */
|
||||
private static function isThemeBuilder(array $row): bool
|
||||
{
|
||||
return in_array($row['type'], DiviThemeBuilder::postTypes(), true);
|
||||
}
|
||||
|
||||
/** @return array{visible: bool, usable: bool} */
|
||||
private function themeBuilder(): array
|
||||
{
|
||||
if ($this->themeBuilderState !== null) {
|
||||
return $this->themeBuilderState;
|
||||
}
|
||||
|
||||
if ($this->themeBuilder !== null) {
|
||||
$state = ($this->themeBuilder)();
|
||||
} else {
|
||||
$open = $this->roles->allows('theme_builder')
|
||||
&& current_user_can('edit_others_posts')
|
||||
&& current_user_can('edit_theme_options');
|
||||
$state = ['visible' => $open, 'usable' => $open];
|
||||
}
|
||||
|
||||
return $this->themeBuilderState = [
|
||||
'visible' => (bool) ($state['visible'] ?? false),
|
||||
'usable' => (bool) ($state['usable'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function db(): \wpdb
|
||||
{
|
||||
return $this->db ?? $GLOBALS['wpdb'];
|
||||
|
||||
@@ -80,6 +80,17 @@ class LibraryAccess
|
||||
return $this->roles->refusal('divi_library');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P13: whether library IDs may be named to this connection at all – the library group
|
||||
* is not disabled (P9; "nur lesen" still names them) and the user has the library right
|
||||
* (canList). Otherwise outlines, node-get, write results, text search and the render summary
|
||||
* say "global module" without the entry's ID, as if it were not stored.
|
||||
*/
|
||||
public function revealsIds(): bool
|
||||
{
|
||||
return ($this->visible === null || ($this->visible)()) && $this->canList() === null;
|
||||
}
|
||||
|
||||
/** Reading one entry: canList() and edit_post on the entry. */
|
||||
public function canRead(WP_Post $item): ?WP_Error
|
||||
{
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<?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\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/library-find-similar (spec §5.7, E11): nodes across the site built like a reference node (post_id + id) or a
|
||||
* library entry (library_id) – "identical" (Fingerprint::content) or "structure" (Fingerprint::structure, other
|
||||
* values). Scans at most MAX_POSTS entries per call in ID order (cursor), only of Divi-enabled managed types – never
|
||||
* library entries or Theme Builder layouts, which have their own gates –, only entries the user may edit (query
|
||||
* restricted to own posts without edit_others_posts, like content-query), no index or meta (F31). Contents come in
|
||||
* chunks of CONTENT_CHUNK through one primed post cache each (no query per post, bounded memory, as GlobalUsages).
|
||||
* A call stops after the post that reaches MAX_MATCHES matches or MAX_NODES fingerprinted nodes; `note` says so
|
||||
* and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like
|
||||
* missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned.
|
||||
*/
|
||||
final class LibraryFindSimilarAbility implements Ability
|
||||
{
|
||||
public const MAX_POSTS = 200;
|
||||
public const DEFAULT_POSTS = 100;
|
||||
public const MAX_MATCHES = 200;
|
||||
|
||||
/** At most this many nodes of the reference's type are fingerprinted per call. */
|
||||
public const MAX_NODES = 5000;
|
||||
|
||||
/** Post contents read with one primed cache (bounded memory). */
|
||||
public const CONTENT_CHUNK = 50;
|
||||
|
||||
|
||||
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
|
||||
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviPageLoader $loader,
|
||||
private ContentSchema $schema,
|
||||
private Parser $parser = new Parser()
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/library-find-similar';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Ähnliche Abschnitte finden';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Sucht seitenübergreifend Knoten, die wie ein gegebener Knoten (post_id und id aus '
|
||||
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
|
||||
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
|
||||
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
|
||||
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) '
|
||||
. 'ersetzt lit/library-make-global (library_id, targets).';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'],
|
||||
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'],
|
||||
'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'],
|
||||
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS],
|
||||
'cursor' => ['type' => 'string'],
|
||||
],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'reference' => ['type' => 'object'],
|
||||
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'scanned' => ['type' => 'integer'],
|
||||
'next_cursor' => ['type' => ['string', 'null']],
|
||||
'note' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['reference', 'matches', 'scanned', 'next_cursor'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::read();
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return $this->access->canList() ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = $this->access->canList();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$reference = $this->reference($input);
|
||||
if ($reference instanceof WP_Error) {
|
||||
return $reference;
|
||||
}
|
||||
|
||||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||||
if ($offset === null) {
|
||||
return Cursor::invalid();
|
||||
}
|
||||
|
||||
$types = $this->postTypes($input['post_type'] ?? null);
|
||||
if ($types instanceof WP_Error) {
|
||||
return $types;
|
||||
}
|
||||
|
||||
$limit = is_int($input['limit'] ?? null)
|
||||
? max(1, min(self::MAX_POSTS, $input['limit']))
|
||||
: self::DEFAULT_POSTS;
|
||||
$args = [
|
||||
'post_type' => $types,
|
||||
'post_status' => self::LIVING,
|
||||
'posts_per_page' => $limit + 1,
|
||||
'offset' => $offset,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'suppress_filters' => false,
|
||||
];
|
||||
foreach ($types as $type) {
|
||||
$own = PostGuard::ownPostsOnly($type);
|
||||
if ($own !== null) {
|
||||
$args['author'] = $own;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_map('intval', (array) get_posts($args)));
|
||||
$scan = array_slice($ids, 0, $limit);
|
||||
|
||||
$matches = [];
|
||||
$nodes = 0;
|
||||
$scanned = 0;
|
||||
foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) {
|
||||
$fresh = _get_non_cached_ids($chunk, 'posts');
|
||||
_prime_post_caches($chunk, false, false);
|
||||
foreach ($chunk as $id) {
|
||||
if (self::full($matches, $nodes)) {
|
||||
break;
|
||||
}
|
||||
++$scanned;
|
||||
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
|
||||
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) {
|
||||
$this->collect($post, $reference, $matches, $nodes);
|
||||
}
|
||||
}
|
||||
foreach ($fresh as $id) {
|
||||
wp_cache_delete((int) $id, 'posts');
|
||||
}
|
||||
if (self::full($matches, $nodes)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$stopped = $scanned < count($scan);
|
||||
$result = [
|
||||
'reference' => $reference['info'],
|
||||
'matches' => $matches,
|
||||
'scanned' => $scanned,
|
||||
'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null,
|
||||
];
|
||||
if (self::full($matches, $nodes)) {
|
||||
$result['note'] = sprintf(
|
||||
'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); '
|
||||
. 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type '
|
||||
. 'eingrenzen.',
|
||||
$scanned,
|
||||
self::MAX_MATCHES,
|
||||
self::MAX_NODES
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $matches */
|
||||
private static function full(array $matches, int $nodes): bool
|
||||
{
|
||||
return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string}|WP_Error
|
||||
*/
|
||||
private function reference(array $input): array|WP_Error
|
||||
{
|
||||
if (isset($input['library_id'])) {
|
||||
$item = $this->access->item($input['library_id'], false);
|
||||
if ($item === null) {
|
||||
return PostGuard::notFound((int) PostGuard::id($input['library_id']));
|
||||
}
|
||||
if ($item instanceof WP_Error) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
try {
|
||||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||||
} catch (MalformedMarkup) {
|
||||
$roots = [];
|
||||
}
|
||||
if (count($roots) !== 1 || $roots[0]->isOpaque()) {
|
||||
return Errors::invalidInput(
|
||||
sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID),
|
||||
['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul']
|
||||
);
|
||||
}
|
||||
|
||||
return self::describe($roots[0], null, null, (int) $item->ID);
|
||||
}
|
||||
|
||||
if (! isset($input['post_id'])) {
|
||||
return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [
|
||||
'library_id' => 'oder post_id und id',
|
||||
]);
|
||||
}
|
||||
|
||||
$page = $this->loader->load($input['post_id'], false);
|
||||
if ($page instanceof WP_Error) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||||
$node = $page->index->find($id);
|
||||
if ($node === null) {
|
||||
return DiviErrors::unknownNode($id, $page->post);
|
||||
}
|
||||
if ($node->isOpaque()) {
|
||||
return Errors::invalidInput(
|
||||
'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.',
|
||||
['id' => 'lesbarer Knoten']
|
||||
);
|
||||
}
|
||||
|
||||
return self::describe($node, (int) $page->post->ID, $id, null);
|
||||
}
|
||||
|
||||
/** @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} */
|
||||
private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array
|
||||
{
|
||||
$info = array_filter([
|
||||
'type' => $block->name(),
|
||||
'structure' => Fingerprint::structure($block),
|
||||
'content' => Fingerprint::content($block),
|
||||
'post_id' => $postId,
|
||||
'id' => $id,
|
||||
'library_id' => $libraryId,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
|
||||
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them
|
||||
* (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function notScanned(): array
|
||||
{
|
||||
return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes());
|
||||
}
|
||||
|
||||
/** @return list<string>|WP_Error */
|
||||
private function postTypes(mixed $postType): array|WP_Error
|
||||
{
|
||||
if ($postType !== null) {
|
||||
// Review M-7: an excluded type answers like any unavailable one, by its name.
|
||||
$type = in_array($postType, self::notScanned(), true)
|
||||
? PostGuard::unavailableType((string) $postType)
|
||||
: PostGuard::postType($postType, $this->schema, 'edit_posts');
|
||||
|
||||
return $type instanceof WP_Error ? $type : [(string) $postType];
|
||||
}
|
||||
|
||||
$types = array_values(array_filter(
|
||||
array_diff($this->schema->postTypeNames(), self::notScanned()),
|
||||
fn (string $type): bool => $this->schema->diviEnabled($type)
|
||||
));
|
||||
|
||||
return $types !== [] ? $types : Errors::notFound(
|
||||
'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.',
|
||||
'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} $reference
|
||||
* @param list<array<string, mixed>> $matches
|
||||
*/
|
||||
private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void
|
||||
{
|
||||
$content = (string) $post->post_content;
|
||||
if (! str_contains($content, '<!-- wp:divi/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$index = NodeIndex::build($this->parser->parse($content));
|
||||
} catch (MalformedMarkup) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $reference['block']->name();
|
||||
foreach ($index->entries() as $entry) {
|
||||
$block = $entry['block'];
|
||||
$self = (int) $post->ID === $reference['post_id'] && $entry['id'] === $reference['id'];
|
||||
if ($self || $block->isOpaque() || $block->name() !== $name) {
|
||||
continue;
|
||||
}
|
||||
if (++$nodes > self::MAX_NODES) {
|
||||
return;
|
||||
}
|
||||
if (Fingerprint::structure($block) !== $reference['info']['structure']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identical = Fingerprint::content($block) === $reference['info']['content'];
|
||||
$matches[] = [
|
||||
'post_id' => (int) $post->ID,
|
||||
'title' => (string) $post->post_title,
|
||||
'status' => (string) $post->post_status,
|
||||
'type' => (string) $post->post_type,
|
||||
'id' => $entry['id'],
|
||||
'label' => NodeSummary::label($block),
|
||||
'tree_rev' => Rev::of($content),
|
||||
'match' => $identical ? 'identical' : 'structure',
|
||||
// Review M-8: lit/library-make-global replaces identical occurrences in pages and posts only.
|
||||
'replaceable' => $identical
|
||||
&& in_array((string) $post->post_type, LibraryMakeGlobalAbility::POST_TYPES, true),
|
||||
];
|
||||
if (count($matches) >= self::MAX_MATCHES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,24 @@ 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;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit-library-insert (direct), with the
|
||||
* content schema lit/library-find-similar (E11), and lit/library-make-global (E10).
|
||||
* 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. */
|
||||
/**
|
||||
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always.
|
||||
* @param GlobalUsages|null $usages The usages with the Theme Builder access (DiviIntegration::globalUsages(),
|
||||
* review I-2); null = GlobalUsages' own fail-safe Theme Builder check.
|
||||
*/
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviPageLoader $loader,
|
||||
@@ -32,7 +38,9 @@ final class LibraryIntegration implements Integration
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DiviModules $modules,
|
||||
private ?Closure $diviWritable = null
|
||||
private ?Closure $diviWritable = null,
|
||||
private ?ContentSchema $schema = null,
|
||||
private ?GlobalUsages $usages = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -53,12 +61,12 @@ final class LibraryIntegration implements Integration
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
return [
|
||||
$abilities = [
|
||||
new LibraryListAbility(
|
||||
$this->access,
|
||||
$this->loader,
|
||||
$this->design,
|
||||
new GlobalUsages(null, $this->access->roles())
|
||||
$this->usages ?? new GlobalUsages(null, $this->access->roles())
|
||||
),
|
||||
new LibrarySaveAbility(
|
||||
$this->access,
|
||||
@@ -70,5 +78,17 @@ final class LibraryIntegration implements Integration
|
||||
),
|
||||
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
||||
];
|
||||
if ($this->schema !== null) {
|
||||
$abilities[] = new LibraryFindSimilarAbility($this->access, $this->loader, $this->schema);
|
||||
}
|
||||
$abilities[] = new LibraryMakeGlobalAbility(
|
||||
$this->access,
|
||||
$this->writer,
|
||||
new NewLibraryEntry($this->access, $this->saver, $this->modules),
|
||||
$this->log,
|
||||
$this->diviWritable
|
||||
);
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
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\Fingerprint;
|
||||
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\DiviErrors;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||
use LIT\MCP\Log;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/library-make-global (spec §5.7, E10): a node becomes a global module – a new global library entry holding
|
||||
* exactly its bytes (NewLibraryEntry, the lit/library-save path: rights with scope "global", ruling P7,
|
||||
* StoredNodeGuard, no reference to any entry in any spelling, ruling Q7) and the node replaced on its page by the
|
||||
* instance the Visual Builder writes (GlobalInstance::of). All or nothing: the page is written through
|
||||
* DiviWriter::commit() (tree_rev lock, revision); when it is refused or rolled back, the new entry is deleted
|
||||
* again (NewDraft) – only a page write that may have been saved (partial) keeps the entry, because the page may
|
||||
* already show it. Sources are pages and posts only (post types page and post, ruling Q25): no library entries
|
||||
* (no nesting, ruling P11), no Theme Builder layouts (ruling Q24; there lit/library-save and lit-library-insert)
|
||||
* and no other Divi-enabled types. Needs the rights of
|
||||
* lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2).
|
||||
*
|
||||
* Second mode (E10, answer F2): library_id plus targets replaces further occurrences – only nodes whose content
|
||||
* fingerprint and normalized bytes (Fingerprint::canonical, ruling Q25: visible text parts included) equal the
|
||||
* entry's root ("identical" in lit/library-find-similar) – by instances of that active
|
||||
* global entry (one readable root, no reference to any entry in any spelling, ruling Q7). A global edit: every
|
||||
* target is checked first (edit_post and tree_rev through DiviWriter::page(), P8a – the account changes only pages
|
||||
* it may edit; identical nodes; the instance passes the page's StoredNodeGuard; post types page and post only),
|
||||
* then a dry run lists the pages (tree_rev, status, live – drafts are listed, like P8a), and only confirm: true
|
||||
* writes. Every page goes through DiviWriter::commit() with its own tree_rev
|
||||
* lock, revision and change-log row; a live page (publish, private, future) without revisions first gets
|
||||
* VisibleEdits' backup, refused for every page before anything is written where it could not be faithful (P9).
|
||||
* Pages are written in the given order; when page N fails, pages 1..N-1 stay replaced (each with its restore
|
||||
* point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error
|
||||
* names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10).
|
||||
*/
|
||||
final class LibraryMakeGlobalAbility implements Ability
|
||||
{
|
||||
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
|
||||
. 'entfernt.';
|
||||
|
||||
public const MAX_TARGETS = 20;
|
||||
|
||||
public const MAX_IDS = 20;
|
||||
|
||||
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
|
||||
|
||||
/** Post types that are sources and targets (ruling Q25): pages and posts, no other Divi-enabled type. */
|
||||
public const POST_TYPES = ['page', 'post'];
|
||||
|
||||
/** Inputs of the first mode, refused next to library_id. */
|
||||
private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags'];
|
||||
|
||||
private VisibleEdits $visible;
|
||||
|
||||
/**
|
||||
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always.
|
||||
* @param VisibleEdits|null $visible Backup of live pages without revisions; null = the default.
|
||||
*/
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviWriter $writer,
|
||||
private NewLibraryEntry $entries,
|
||||
private ChangeLog $log,
|
||||
private ?Closure $diviWritable = null,
|
||||
?VisibleEdits $visible = null,
|
||||
private Parser $parser = new Parser()
|
||||
) {
|
||||
$this->visible = $visible ?? new VisibleEdits();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/library-make-global';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'In globales Modul umwandeln';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder '
|
||||
. 'in einem Beitrag (Inhaltstypen page und post) in ein globales Modul um: legt einen globalen '
|
||||
. 'Bibliothekseintrag mit genau diesem Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – '
|
||||
. 'alles oder nichts. Danach zeigt die '
|
||||
. 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.'
|
||||
. ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids '
|
||||
. 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"; nur Seiten und Beiträge); das '
|
||||
. 'ist zuerst ein Probelauf mit den betroffenen Seiten und braucht dann confirm: true. Jede Seite bekommt '
|
||||
. 'eine Revision.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(DiviSchema::write(), [
|
||||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||||
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||||
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||||
'library_id' => [
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'description' => 'Globaler Eintrag, dessen inhaltsgleiche Vorkommen ersetzt werden (mit targets).',
|
||||
],
|
||||
'targets' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => self::MAX_TARGETS,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'tree_rev' => ['type' => 'string'],
|
||||
'ids' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => self::MAX_IDS,
|
||||
'items' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
'required' => ['post_id', 'tree_rev', 'ids'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
],
|
||||
]),
|
||||
'required' => [],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'library_id' => ['type' => 'integer'],
|
||||
'tree_rev' => ['type' => 'string'],
|
||||
'pages' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
], ['library_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(false);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return $this->precheck($input) ?? true;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed */
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = $this->precheck($input);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
return self::replaces($input) ? $this->replaceOccurrences($input) : $this->makeGlobal($input);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input Whether the call replaces occurrences (second mode). */
|
||||
private static function replaces(array $input): bool
|
||||
{
|
||||
return array_key_exists('library_id', $input) || array_key_exists('targets', $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rights of lit-library-insert (add_library, library group writable) and – for a new entry only – of
|
||||
* lit/library-save with scope "global" (publish_posts, save_library, edit_global_library), and
|
||||
* lit-divi-insert's Tools-tab state (M2).
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
private function precheck(array $input): ?WP_Error
|
||||
{
|
||||
$refusal = $this->access->canInsert() ?? (self::replaces($input) ? null : $this->access->canSave(true));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function makeGlobal(array $input): array|WP_Error
|
||||
{
|
||||
$page = $this->writer->page($input);
|
||||
if ($page instanceof WP_Error) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||||
$node = $page->index->find($id);
|
||||
if ($node === null) {
|
||||
return DiviErrors::unknownNode($id, $page->post);
|
||||
}
|
||||
|
||||
$refusal = self::sourceRefusal($page);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
// LibraryItemSpec::forNode() refuses instances and nodes holding one, NewLibraryEntry every other
|
||||
// spelling of a reference (rulings P11, Q7, B17) – one rule, no copy here.
|
||||
$spec = $this->entries->spec($page, $node, $input, true, $this->writer->composition($page)->storedGuard());
|
||||
if ($spec instanceof WP_Error) {
|
||||
return $spec;
|
||||
}
|
||||
|
||||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||||
$created = $this->entries->create($spec, $node, $draft);
|
||||
$libraryId = $created['id'];
|
||||
|
||||
$root = $node->copy();
|
||||
$root->unifyLineBreaks();
|
||||
$instance = GlobalInstance::of($root, $libraryId);
|
||||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||||
$change = (new DiviChange(sprintf(
|
||||
'Knoten %s in das globale Modul „%s“ (%d) umgewandelt',
|
||||
$id,
|
||||
$spec->title,
|
||||
$libraryId
|
||||
)))->record($instance, 'replaced');
|
||||
|
||||
try {
|
||||
$result = $this->writer->commit($this->name(), $page, $change, $input);
|
||||
} catch (WriteFailed $e) {
|
||||
if (! $e->rolledBack) {
|
||||
// The page may already show the instance: deleting the entry would leave it empty.
|
||||
throw $e->withDetails(
|
||||
['library_id' => $libraryId],
|
||||
sprintf(
|
||||
'Den Aufruf nicht einfach wiederholen. Der neue globale Eintrag %d bleibt bestehen, weil die '
|
||||
. 'Seite ihn möglicherweise schon zeigt; den Stand mit lit-divi-outline prüfen und dem '
|
||||
. 'Nutzer Bescheid geben.',
|
||||
$libraryId
|
||||
)
|
||||
);
|
||||
}
|
||||
$draft->discard($libraryId, $e, self::REASON_PAGE);
|
||||
}
|
||||
|
||||
if ($result instanceof WP_Error) {
|
||||
return $draft->refuse($libraryId, $result);
|
||||
}
|
||||
|
||||
return $this->result($result, $spec, $libraryId, $created['warnings'], $page, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function replaceOccurrences(array $input): array|WP_Error
|
||||
{
|
||||
$mixed = array_values(array_intersect(self::NODE_INPUT, array_keys($input)));
|
||||
if ($mixed !== [] || ! array_key_exists('library_id', $input) || ! array_key_exists('targets', $input)) {
|
||||
return Errors::invalidInput(
|
||||
'Zum Ersetzen library_id und targets angeben (ohne post_id, id und title); nichts wurde gespeichert.',
|
||||
['library_id' => 'library_id und targets zusammen, ohne ' . implode(', ', $mixed ?: self::NODE_INPUT)]
|
||||
);
|
||||
}
|
||||
|
||||
$root = $this->globalRoot($input['library_id']);
|
||||
if ($root instanceof WP_Error) {
|
||||
return $root;
|
||||
}
|
||||
[$libraryId, $block] = $root;
|
||||
|
||||
$plans = $this->plans($libraryId, $block, $input['targets']);
|
||||
if ($plans instanceof WP_Error) {
|
||||
return $plans;
|
||||
}
|
||||
|
||||
$affected = array_map(static fn (array $plan): array => [
|
||||
'post_id' => (int) $plan[0]->post->ID,
|
||||
'title' => (string) $plan[0]->post->post_title,
|
||||
'status' => (string) $plan[0]->post->post_status,
|
||||
'live' => self::isLive($plan[0]->post),
|
||||
'tree_rev' => $plan[0]->treeRev(),
|
||||
'ids' => array_keys($plan[1]),
|
||||
], $plans);
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
$live = count(array_filter(array_column($affected, 'live')));
|
||||
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'%d Vorkommen auf %d %s (%d davon veröffentlicht, privat oder geplant) würden durch das globale '
|
||||
. 'Modul %d ersetzt; die Seiten zeigen danach den Inhalt aus der Bibliothek. Nichts wurde '
|
||||
. 'gespeichert (Probelauf).',
|
||||
array_sum(array_map(static fn (array $row): int => count($row['ids']), $affected)),
|
||||
count($affected),
|
||||
count($affected) === 1 ? 'Seite' : 'Seiten',
|
||||
$live,
|
||||
$libraryId
|
||||
),
|
||||
['affected' => $affected, 'affected_count' => count($affected), 'live_count' => $live]
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($plans as [$page]) {
|
||||
$refusal = self::isLive($page->post) ? $this->visible->backupRefusal(
|
||||
$page->post,
|
||||
sprintf('Seite %d', (int) $page->post->ID),
|
||||
'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.'
|
||||
) : null;
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->replace($libraryId, $block, $plans);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every target checked before anything is written: page rights and tree_rev (DiviWriter::page(), P8a),
|
||||
* pages and posts only, each once, identical nodes, the instance through the page's StoredNodeGuard.
|
||||
*
|
||||
* @return list<array{0: DiviPage, 1: array<string, Block>}>|WP_Error
|
||||
*/
|
||||
private function plans(int $libraryId, Block $root, mixed $targets): array|WP_Error
|
||||
{
|
||||
$targets = is_array($targets) ? array_values($targets) : [];
|
||||
if ($targets === [] || count($targets) > self::MAX_TARGETS) {
|
||||
return Errors::invalidInput(
|
||||
sprintf('targets fehlt oder hat mehr als %d Seiten; nichts wurde gespeichert.', self::MAX_TARGETS),
|
||||
['targets' => sprintf('1 bis %d Seiten', self::MAX_TARGETS)]
|
||||
);
|
||||
}
|
||||
|
||||
$fingerprint = Fingerprint::content($root);
|
||||
$canonical = Fingerprint::canonical($root);
|
||||
$plans = [];
|
||||
$errors = [];
|
||||
$seen = [];
|
||||
foreach ($targets as $i => $target) {
|
||||
$target = is_array($target) ? $target : [];
|
||||
$page = $this->writer->page([
|
||||
'post_id' => $target['post_id'] ?? null,
|
||||
'tree_rev' => $target['tree_rev'] ?? null,
|
||||
]);
|
||||
if ($page instanceof WP_Error) {
|
||||
return $page;
|
||||
}
|
||||
$postId = (int) $page->post->ID;
|
||||
if (self::sourceRefusal($page) !== null || isset($seen[$postId])) {
|
||||
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge (page, post), jede einmal (andere Einträge '
|
||||
. 'einzeln mit lit-library-insert)';
|
||||
continue;
|
||||
}
|
||||
$seen[$postId] = true;
|
||||
|
||||
$ids = is_array($target['ids'] ?? null) ? array_values($target['ids']) : [];
|
||||
if ($ids === [] || count($ids) > self::MAX_IDS) {
|
||||
$errors["targets.$i.ids"] = sprintf('1 bis %d Pfad-IDs', self::MAX_IDS);
|
||||
}
|
||||
$nodes = [];
|
||||
foreach ($ids as $j => $id) {
|
||||
$node = is_string($id) ? $page->index->find($id) : null;
|
||||
// The hash first (cheap), then the normalized bytes themselves (ruling Q25) – no collision counts.
|
||||
$identical = $node !== null && ! $node->isOpaque() && Fingerprint::content($node) === $fingerprint
|
||||
&& Fingerprint::canonical($node) === $canonical;
|
||||
if (! $identical) {
|
||||
$errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" '
|
||||
. 'aus lit/library-find-similar)';
|
||||
continue;
|
||||
}
|
||||
$nodes[(string) $id] = $node;
|
||||
}
|
||||
|
||||
$guard = $this->writer->composition($page)->storedGuard();
|
||||
$problems = $guard->problems(GlobalInstance::of($root, $libraryId));
|
||||
if ($problems !== []) {
|
||||
$errors["targets.$i.post_id"] = implode('; ', $problems);
|
||||
}
|
||||
$plans[] = [$page, $nodes];
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Nicht alle Ziele lassen sich ersetzen; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
|
||||
return $plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the pages in order (see the class comment for a failure on page N).
|
||||
*
|
||||
* @param list<array{0: DiviPage, 1: array<string, Block>}> $plans
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function replace(int $libraryId, Block $root, array $plans): array
|
||||
{
|
||||
$pages = [];
|
||||
$warnings = [];
|
||||
$changed = [];
|
||||
foreach ($plans as $n => [$page, $nodes]) {
|
||||
$postId = (int) $page->post->ID;
|
||||
$change = new DiviChange(
|
||||
sprintf('%d Vorkommen durch das globale Modul %d ersetzt', count($nodes), $libraryId)
|
||||
);
|
||||
foreach ($nodes as $node) {
|
||||
$instance = GlobalInstance::of($root, $libraryId);
|
||||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||||
$change->record($instance, 'replaced');
|
||||
}
|
||||
|
||||
$rest = array_map(static fn (array $plan): int => (int) $plan[0]->post->ID, array_slice($plans, $n + 1));
|
||||
$backup = [];
|
||||
try {
|
||||
$backup = self::isLive($page->post) ? $this->visible->backup($page->post) : [];
|
||||
$saved = $this->writer->commit(
|
||||
$this->name(),
|
||||
$page,
|
||||
$change,
|
||||
['post_id' => $postId, 'tree_rev' => $page->treeRev(), 'library_id' => $libraryId]
|
||||
);
|
||||
if ($saved instanceof WP_Error) {
|
||||
throw WriteFailed::rolledBack(Errors::clientMessage($saved), detail: 'commit refused the page');
|
||||
}
|
||||
} catch (WriteFailed $e) {
|
||||
$e = VisibleEdits::keepingBackup($e, $backup['backup_id'] ?? null, 'der Seite');
|
||||
throw self::failedOn($postId, $e, $pages, $rest);
|
||||
} catch (Throwable $e) {
|
||||
Log::failure(sprintf('%s on post %d', $this->name(), $postId), $e);
|
||||
$unknown = WriteFailed::partial(
|
||||
'Interner Fehler beim Speichern dieser Seite (Details im PHP-Error-Log der Website).',
|
||||
$e,
|
||||
postId: $postId
|
||||
);
|
||||
throw self::failedOn($postId, $unknown, $pages, $rest);
|
||||
}
|
||||
|
||||
$pages[] = array_merge([
|
||||
'post_id' => $postId,
|
||||
'tree_rev' => (string) ($saved['tree_rev'] ?? ''),
|
||||
'revision_id' => $saved['revision_id'] ?? null,
|
||||
], $backup);
|
||||
$post = $page->post;
|
||||
$changed[] = [(string) $postId, (string) $post->post_type, 'replaced', (string) $post->post_title];
|
||||
foreach (is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [] as $warning) {
|
||||
$warnings[] = sprintf('Eintrag %d: %s', $postId, (string) $warning);
|
||||
}
|
||||
}
|
||||
|
||||
$tool = ToolResult::ok(sprintf(
|
||||
'Vorkommen auf %d %s durch das globale Modul %d ersetzt (je mit Revision).',
|
||||
count($pages),
|
||||
count($pages) === 1 ? 'Seite' : 'Seiten',
|
||||
$libraryId
|
||||
));
|
||||
foreach ($changed as $entry) {
|
||||
$tool->changed(...$entry);
|
||||
}
|
||||
|
||||
return $tool->warnings($warnings)
|
||||
->with('library_id', $libraryId)
|
||||
->with('pages', $pages)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure of page $postId with the state of the whole call: pages already replaced (each with its
|
||||
* revision or backup), the failed one, the ones not attempted.
|
||||
*
|
||||
* @param list<array<string, mixed>> $pages Pages replaced before.
|
||||
* @param list<int> $rest Pages after the failed one.
|
||||
*/
|
||||
private static function failedOn(int $postId, WriteFailed $e, array $pages, array $rest): WriteFailed
|
||||
{
|
||||
$done = array_map(static fn (array $row): int => (int) $row['post_id'], $pages);
|
||||
$details = ['changed_posts' => $done, 'pages' => $pages, 'failed_post' => $postId, 'unchanged_posts' => $rest];
|
||||
if ($done === []) {
|
||||
return $e->withDetails($details);
|
||||
}
|
||||
|
||||
// DiviSaver's own state stays (e.g. "Revision X bleibt erhalten").
|
||||
$state = sprintf(
|
||||
'Seite %d: %s Bereits ersetzt (je mit Revision): %s; unverändert: %s.',
|
||||
$postId,
|
||||
$e->state ?? 'Die Änderung wurde nicht gespeichert.',
|
||||
implode(', ', $done),
|
||||
$rest === [] ? 'keine weiteren' : implode(', ', $rest)
|
||||
);
|
||||
|
||||
// Ruling P14: a backup of page N (revisions off) stays named – its sentence leads the hint.
|
||||
$hint = 'Den Stand mit lit/library-find-similar neu prüfen und nur die übrigen Seiten mit neuem tree_rev '
|
||||
. 'ersetzen.';
|
||||
if (isset($e->details['backup_id']) && $e->hint !== null) {
|
||||
$hint = $e->hint . ' ' . $hint;
|
||||
}
|
||||
|
||||
return $e->withDetails($details, $hint, $e->rolledBack ? $state : null);
|
||||
}
|
||||
|
||||
/** Pages visitors see (or will see): changes there get VisibleEdits' restore point (P8a statuses). */
|
||||
private static function isLive(WP_Post $post): bool
|
||||
{
|
||||
return in_array((string) $post->post_status, GlobalUsages::LIVE, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active global entry $libraryId (LibraryAccess::item(): the library right and edit_post on it) with
|
||||
* its single readable root, holding no global module and naming no entry in any spelling (P11, Q7).
|
||||
*
|
||||
* @return array{0: int, 1: Block}|WP_Error
|
||||
*/
|
||||
private function globalRoot(mixed $libraryId): array|WP_Error
|
||||
{
|
||||
$id = PostGuard::id($libraryId);
|
||||
if ($id === null) {
|
||||
return Errors::invalidInput('library_id ist keine ID; nichts wurde gespeichert.', [
|
||||
'library_id' => 'ID aus lit/library-list',
|
||||
]);
|
||||
}
|
||||
|
||||
$item = $this->access->item($id, false);
|
||||
if ($item === null) {
|
||||
return PostGuard::notFound($id);
|
||||
}
|
||||
if ($item instanceof WP_Error) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
$content = (string) $item->post_content;
|
||||
try {
|
||||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, $content));
|
||||
} catch (MalformedMarkup) {
|
||||
$roots = [];
|
||||
}
|
||||
$valid = (string) $item->post_status === 'publish' && $this->access->isGlobal($item) && count($roots) === 1
|
||||
&& ! $roots[0]->isOpaque() && ! LibraryContent::containsGlobal($roots[0])
|
||||
&& ! UsageMatcher::referencesAny($content);
|
||||
if (! $valid) {
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Bibliothekseintrag %d ist kein aktiver globaler Eintrag mit genau einem Knoten ohne Verweis auf '
|
||||
. 'andere Einträge; nichts wurde gespeichert.',
|
||||
$id
|
||||
),
|
||||
['library_id' => 'globaler Eintrag (lit/library-list, scope "global")']
|
||||
);
|
||||
}
|
||||
|
||||
return [$id, $roots[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only pages and posts (POST_TYPES, ruling Q25): a library entry would nest global modules (P11), a Theme
|
||||
* Builder layout is left to lit/library-save plus lit-library-insert (ruling Q24), other Divi-enabled types
|
||||
* are out of scope.
|
||||
*/
|
||||
private static function sourceRefusal(DiviPage $page): ?WP_Error
|
||||
{
|
||||
if ($page->isLibraryItem()) {
|
||||
return Errors::invalidInput(
|
||||
'Knoten in Bibliothekseinträgen lassen sich nicht in globale Module umwandeln (keine '
|
||||
. 'Verschachtelung); nichts wurde angelegt.',
|
||||
['post_id' => 'eine Seite oder ein Beitrag']
|
||||
);
|
||||
}
|
||||
|
||||
if ($page->isThemeBuilderLayout()) {
|
||||
return Errors::invalidInput(
|
||||
'Knoten in Theme-Builder-Layouts wandelt lit/library-make-global nicht um; nichts wurde angelegt.',
|
||||
['post_id' => 'eine Seite oder ein Beitrag – im Layout mit lit/library-save (scope "global") und '
|
||||
. 'lit-library-insert (mode "global")']
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array((string) $page->post->post_type, self::POST_TYPES, true)) {
|
||||
return Errors::invalidInput(
|
||||
'lit/library-make-global arbeitet nur auf Seiten und Beiträgen; nichts wurde gespeichert.',
|
||||
['post_id' => 'eine Seite oder ein Beitrag (Inhaltstyp page oder post)']
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's Divi write result plus the new entry: lit_mcp_after_save and a change-log row for the entry
|
||||
* (like lit/library-save), library_id and the hint on further occurrences.
|
||||
*
|
||||
* @param array<string, mixed> $saved
|
||||
* @param list<string> $entryWarnings
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(
|
||||
array $saved,
|
||||
LibraryItemSpec $spec,
|
||||
int $libraryId,
|
||||
array $entryWarnings,
|
||||
DiviPage $page,
|
||||
array $input
|
||||
): array {
|
||||
$entry = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $entryWarnings))
|
||||
->afterSave($input)
|
||||
->log(DiviLibraryStore::POST_TYPE, sprintf(
|
||||
'Bibliothek: „%s“ angelegt (%s, global) aus Eintrag %d und dort als Instanz eingesetzt',
|
||||
$spec->title,
|
||||
self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType,
|
||||
(int) $page->post->ID
|
||||
));
|
||||
// The entry's warnings, including those of its follow-up (listener, change log).
|
||||
$warnings = $entry->result('', null)->toArray()['warnings'];
|
||||
|
||||
$saved['library_id'] = $libraryId;
|
||||
$saved['warnings'] = array_merge(
|
||||
is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [],
|
||||
$warnings,
|
||||
[sprintf(
|
||||
'Gleichartige Vorkommen: lit/library-find-similar mit library_id %d; inhaltsgleiche („identical“) '
|
||||
. 'mit lit/library-make-global (library_id, targets) durch das globale Modul ersetzen.',
|
||||
$libraryId
|
||||
)]
|
||||
);
|
||||
|
||||
return $saved;
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,15 @@ 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;
|
||||
|
||||
@@ -34,8 +30,9 @@ defined('ABSPATH') || exit;
|
||||
* 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
|
||||
* to another post; a global entry names no other entry in any spelling (ruling Q7). Spec and creation
|
||||
* are NewLibraryEntry, shared with lit/library-make-global. The source is never written – linking it
|
||||
* to the entry is lit/library-make-global. 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
|
||||
@@ -44,16 +41,17 @@ final class LibrarySaveAbility implements Ability
|
||||
|
||||
private const SCOPES = ['normal', 'global'];
|
||||
|
||||
private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||||
private NewLibraryEntry $entries;
|
||||
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviPageLoader $loader,
|
||||
private DiviWriter $writer,
|
||||
private DiviSaver $saver,
|
||||
DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DiviModules $modules
|
||||
DiviModules $modules
|
||||
) {
|
||||
$this->entries = new NewLibraryEntry($access, $saver, $modules);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -158,43 +156,18 @@ final class LibrarySaveAbility implements Ability
|
||||
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(
|
||||
$spec = $this->entries->spec(
|
||||
$page,
|
||||
$node,
|
||||
$this->modules->catalog(),
|
||||
$title,
|
||||
$input,
|
||||
self::isGlobal($input),
|
||||
$this->builtFor($page)
|
||||
$this->writer->composition($page)->storedGuard()
|
||||
);
|
||||
if (is_string($spec)) {
|
||||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||||
if ($spec instanceof WP_Error) {
|
||||
return $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);
|
||||
return $this->create($page, $spec, $node, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,20 +175,19 @@ final class LibrarySaveAbility implements Ability
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array
|
||||
private function create(DiviPage $page, LibraryItemSpec $spec, Block $node, array $input): array
|
||||
{
|
||||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||||
$libraryId = $this->access->store()->create($spec, $draft);
|
||||
$new = $this->entries->create($spec, $node, $draft);
|
||||
$libraryId = $new['id'];
|
||||
|
||||
$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);
|
||||
$followUp = (new PostSaveFollowUp(
|
||||
$this->name(),
|
||||
$this->log,
|
||||
$libraryId,
|
||||
'der Bibliothekseintrag',
|
||||
$new['warnings']
|
||||
))->afterSave($input);
|
||||
$created = $followUp->reread();
|
||||
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
|
||||
$followUp->log(
|
||||
@@ -223,9 +195,10 @@ final class LibrarySaveAbility implements Ability
|
||||
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
|
||||
);
|
||||
|
||||
$content = $created !== null ? (string) $created->post_content : NewLibraryEntry::content($node);
|
||||
$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))
|
||||
->with('tree_rev', Rev::of($content))
|
||||
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||||
if ($spec->global) {
|
||||
$result->warning(
|
||||
@@ -237,48 +210,9 @@ final class LibrarySaveAbility implements Ability
|
||||
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,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Term;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A new Divi Library entry from a node of an entry (E4; lit/library-save and lit/library-make-global): the spec
|
||||
* (title, LibraryItemSpec::forNode, StoredNodeGuard – the node moves to another post, E6 –, existing categories
|
||||
* and tags) and the creation (DiviLibraryStore::create, then the node's bytes in the divi/placeholder frame with
|
||||
* line breaks unified, stored by DiviSaver::saveNew with SaveScope::LibraryItem – the one content write, D7).
|
||||
* A global entry never names another entry in any spelling Divi renders (ruling Q7, UsageMatcher::referencesAny):
|
||||
* no nesting, and no value that makes Divi render the newest layout – the new entry itself.
|
||||
* All or nothing through NewDraft (ruling P6).
|
||||
*/
|
||||
final class NewLibraryEntry
|
||||
{
|
||||
public const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||||
|
||||
/** Ruling Q7: why a node that names a library entry in any spelling cannot become a global entry. */
|
||||
public const NESTED = 'Der Knoten verweist auf einen Bibliothekseintrag (globales Modul, auch als Divi-4-Shortcode '
|
||||
. 'mit global_module in einem Text); als normalen Eintrag speichern oder den Verweis entfernen.';
|
||||
|
||||
public function __construct(
|
||||
private LibraryAccess $access,
|
||||
private DiviSaver $saver,
|
||||
private DiviModules $modules
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input title, categories, tags */
|
||||
public function spec(
|
||||
DiviPage $page,
|
||||
Block $node,
|
||||
array $input,
|
||||
bool $global,
|
||||
StoredNodeGuard $guard
|
||||
): LibraryItemSpec|WP_Error {
|
||||
$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, $global, $this->builtFor($page));
|
||||
if (is_string($spec)) {
|
||||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||||
}
|
||||
|
||||
// Ruling Q7: forNode() sees instances; Divi also renders references in other spellings (P23/P24).
|
||||
if ($global && UsageMatcher::referencesAny(self::content($node))) {
|
||||
return Errors::invalidInput(
|
||||
'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||||
. 'Verschachtelung); nichts wurde angelegt.',
|
||||
['id' => self::NESTED]
|
||||
);
|
||||
}
|
||||
|
||||
$problems = $guard->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 = self::termIds('layout_category', $input['categories'] ?? []);
|
||||
$tags = self::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 $spec->withTerms($categories, $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, warnings: list<string>}
|
||||
* @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6).
|
||||
*/
|
||||
public function create(LibraryItemSpec $spec, Block $node, NewDraft $draft): array
|
||||
{
|
||||
$id = $this->access->store()->create($spec, $draft);
|
||||
$warnings = [];
|
||||
try {
|
||||
$warnings = $this->saver->saveNew($id, self::content($node), SaveScope::LibraryItem);
|
||||
} catch (Throwable $e) {
|
||||
$draft->discard($id, $e, self::REASON_LAYOUT);
|
||||
}
|
||||
|
||||
return ['id' => $id, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
|
||||
public 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';
|
||||
}
|
||||
|
||||
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||||
private static 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,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||||
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;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The quality warning "similar section" (spec §5.5, D6, E11): an inserted section built like a section entry of
|
||||
* the Divi Library (Fingerprint::structure) – compared with library entries only (small; at most MAX_ITEMS, the
|
||||
* ones the user may edit), read only when a write inserts a section and then once per request (kept in memory; no
|
||||
* index or meta, F31). Global instances are already linked to the library and never warn. Only while library IDs
|
||||
* may be named (ruling P13). The warning names the entry's title and ID, never the page's texts. Not final so the
|
||||
* writer can be tested with a mock.
|
||||
*/
|
||||
class SimilarSections
|
||||
{
|
||||
public const MAX_ITEMS = 200;
|
||||
|
||||
/** @var array<string, array{id: int, title: string}>|null Structure fingerprint => entry. */
|
||||
private ?array $index = null;
|
||||
|
||||
public function __construct(private LibraryAccess $access, private Parser $parser = new Parser())
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function warnings(DiviChange $change): array
|
||||
{
|
||||
if (! $this->access->revealsIds()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
foreach ($change->entries() as $entry) {
|
||||
$block = $entry['block'];
|
||||
if (
|
||||
$entry['action'] !== 'inserted'
|
||||
|| ! $block instanceof Block
|
||||
|| $block->name() !== 'divi/section'
|
||||
|| $block->isOpaque()
|
||||
|| $block->isGlobal()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$match = $this->index()[Fingerprint::structure($block)] ?? null;
|
||||
if ($match !== null) {
|
||||
$warnings[] = sprintf(
|
||||
'Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek: „%s“ (%d) – lit-library-insert '
|
||||
. 'erwägen; weitere Vorkommen mit lit/library-find-similar.',
|
||||
$match['title'],
|
||||
$match['id']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($warnings));
|
||||
}
|
||||
|
||||
/** @return array<string, array{id: int, title: string}> */
|
||||
private function index(): array
|
||||
{
|
||||
if ($this->index !== null) {
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
$this->index = [];
|
||||
foreach ($this->access->store()->find(['layout_type' => 'section'], self::MAX_ITEMS - 1, 0) as $item) {
|
||||
if (! current_user_can('edit_post', (int) $item->ID)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||||
} catch (MalformedMarkup) {
|
||||
continue;
|
||||
}
|
||||
if (count($roots) === 1 && $roots[0]->name() === 'divi/section') {
|
||||
$this->index[Fingerprint::structure($roots[0])] ??= [
|
||||
'id' => (int) $item->ID,
|
||||
'title' => (string) $item->post_title,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->index;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ defined('ABSPATH') || exit;
|
||||
* 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.
|
||||
* referencesAny() asks the same for every entry at once (ruling Q7): content that becomes a global entry
|
||||
* must not render another entry – nor, through a value that casts to 0, the newest layout, which may be
|
||||
* that entry itself.
|
||||
* Pure PHP.
|
||||
*/
|
||||
final class UsageMatcher
|
||||
@@ -34,7 +37,25 @@ final class UsageMatcher
|
||||
|
||||
public static function shows(string $content, int $id): bool
|
||||
{
|
||||
$textual = preg_match('/"globalModule":"0*' . $id . '"/', $content);
|
||||
return self::matches($content, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $content renders any library entry, in every spelling shows() knows (ruling Q7): a
|
||||
* globalModule key with any value, a global_module attribute Divi does not skip; fail closed.
|
||||
*/
|
||||
public static function referencesAny(string $content): bool
|
||||
{
|
||||
return self::matches($content, null);
|
||||
}
|
||||
|
||||
/** shows() for entry $id, or for any entry when $id is null. */
|
||||
private static function matches(string $content, ?int $id): bool
|
||||
{
|
||||
$textual = preg_match(
|
||||
$id === null ? '/"globalModule"\s*:/' : '/"globalModule":"0*' . $id . '"/',
|
||||
$content
|
||||
);
|
||||
if ($textual !== 0) {
|
||||
return true; // A match, or a regex failure (fail closed).
|
||||
}
|
||||
@@ -73,8 +94,8 @@ final class UsageMatcher
|
||||
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
|
||||
/** Whether a Divi 4 shortcode in $text renders entry $id (null: any entry); a value casting to 0 renders any. */
|
||||
private static function shortcodeShows(string $text, ?int $id): bool
|
||||
{
|
||||
if (stripos($text, 'global_module') === false) {
|
||||
return false;
|
||||
@@ -95,7 +116,7 @@ final class UsageMatcher
|
||||
continue;
|
||||
}
|
||||
$cast = (int) $value;
|
||||
if ($cast === 0 || $cast === $id) {
|
||||
if ($id === null || $cast === 0 || $cast === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -140,10 +161,16 @@ final class UsageMatcher
|
||||
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
|
||||
/**
|
||||
* @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id (null: any
|
||||
* globalModule key, whatever its value – fail closed).
|
||||
*/
|
||||
private static function names(array $attrs, ?int $id): bool
|
||||
{
|
||||
foreach ($attrs as $key => $value) {
|
||||
if ($id === null && $key === 'globalModule') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
$key === 'globalModule'
|
||||
&& (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1))
|
||||
|
||||
@@ -36,6 +36,7 @@ defined('ABSPATH') || exit;
|
||||
class DiviComposition
|
||||
{
|
||||
private ?int $postId = null;
|
||||
private ?string $themeBuilderArea = null;
|
||||
private ?VariableRef $references = null;
|
||||
private ?Placeholders $placeholders = null;
|
||||
private ?ValueCleaner $cleaner = null;
|
||||
@@ -52,11 +53,12 @@ class DiviComposition
|
||||
) {
|
||||
}
|
||||
|
||||
/** A fresh composition for entry $postId (own VariableRef with that entry's live map). */
|
||||
public function forPost(int $postId): static
|
||||
/** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */
|
||||
public function forPost(int $postId, ?string $themeBuilderArea = null): static
|
||||
{
|
||||
$bound = clone $this;
|
||||
$bound->postId = $postId;
|
||||
$bound->themeBuilderArea = $themeBuilderArea;
|
||||
$bound->references = null;
|
||||
$bound->placeholders = null;
|
||||
$bound->cleaner = null;
|
||||
@@ -116,7 +118,7 @@ class DiviComposition
|
||||
/** @internal Public for tests; code uses composer() / editor(). */
|
||||
public function rules(): StructureRules
|
||||
{
|
||||
return new StructureRules($this->modules->catalog());
|
||||
return new StructureRules($this->modules->catalog(), $this->themeBuilderArea);
|
||||
}
|
||||
|
||||
/** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */
|
||||
|
||||
@@ -6,8 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
@@ -70,7 +68,7 @@ final class DiviContentComposer implements ContentComposer
|
||||
);
|
||||
}
|
||||
|
||||
$layout = $this->layout($this->composition->forPost(0), $sections);
|
||||
$layout = LayoutBuilder::build($this->composition->forPost(0), $sections, 'divi');
|
||||
|
||||
return $layout instanceof WP_Error ? $layout : null;
|
||||
}
|
||||
@@ -78,7 +76,7 @@ final class DiviContentComposer implements ContentComposer
|
||||
public function write(int $postId, string $postType, array $sections): array|WP_Error
|
||||
{
|
||||
$composition = $this->composition->forPost($postId);
|
||||
$layout = $this->layout($composition, $sections);
|
||||
$layout = LayoutBuilder::build($composition, $sections, 'divi');
|
||||
if ($layout instanceof WP_Error) {
|
||||
return $layout;
|
||||
}
|
||||
@@ -98,37 +96,4 @@ final class DiviContentComposer implements ContentComposer
|
||||
'edit_url' => DiviPage::builderUrl($postId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole description composed and inserted into an empty document – or every error.
|
||||
*
|
||||
* @param list<mixed> $sections
|
||||
* @return array{document: Document, blocks: list<Block>}|WP_Error
|
||||
*/
|
||||
private function layout(DiviComposition $composition, array $sections): array|WP_Error
|
||||
{
|
||||
if ($sections === []) {
|
||||
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
|
||||
'divi' => 'mindestens ein Abschnitt',
|
||||
]);
|
||||
}
|
||||
|
||||
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
|
||||
if ($composed['errors'] !== []) {
|
||||
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
$violation = $composition->editor()->insert(
|
||||
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
|
||||
$composed['blocks']
|
||||
);
|
||||
if ($violation !== null) {
|
||||
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
|
||||
'divi' => $violation,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['document' => $document, 'blocks' => $composed['blocks']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ 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;
|
||||
@@ -27,24 +25,18 @@ defined('ABSPATH') || exit;
|
||||
*
|
||||
* 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).
|
||||
* left out with its reason (DynamicFields). 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;
|
||||
private DynamicFields $fields;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||||
@@ -59,7 +51,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
?Closure $podStorage = null
|
||||
) {
|
||||
$this->samplePost = $samplePost ?? self::defaultSample(...);
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
$this->fields = new DynamicFields($schema, $podStorage);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -182,7 +174,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
|
||||
[$fields, $omitted] = $this->fields($postType);
|
||||
[$fields, $omitted] = $this->fields->offered($postType);
|
||||
} else {
|
||||
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
|
||||
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
|
||||
@@ -205,63 +197,16 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
return '{{post:' . $post . '}}';
|
||||
}
|
||||
|
||||
if (! str_starts_with($name, 'post_link_url_')) {
|
||||
if (! str_starts_with($name, DynamicContentRules::LINK_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Review M-9: {{link:}} refuses attachments (status inherit); internal types ("_…") are no link targets.
|
||||
$type = substr($name, strlen('post_link_url_'));
|
||||
// Review M-9/P10: {{link:}} and the full form refuse attachments; internal types ("_…") are no link targets.
|
||||
$type = substr($name, strlen(DynamicContentRules::LINK_PREFIX));
|
||||
|
||||
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
|
||||
{
|
||||
@@ -283,19 +228,4 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +130,22 @@ final class DiviNodeGetAbility implements Ability
|
||||
$version = $block->attrs()['builderVersion'] ?? null;
|
||||
|
||||
$global = NodeSummary::globalRef($block);
|
||||
if ($global !== null) {
|
||||
if ($global !== null && $this->loader->libraryIdsVisible()) {
|
||||
$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']
|
||||
);
|
||||
} elseif ($global !== null) {
|
||||
// Ruling P13: which entry is shown stays unknown without library access (AttrMap::flatten
|
||||
// already leaves globalModule/globalParent out of attrs).
|
||||
$global = [
|
||||
'id' => null,
|
||||
'block_name' => $global['block_name'],
|
||||
'hint' => 'Globales Modul aus der Divi-Bibliothek – nur lesbar; dieses Konto hat keinen Zugang zur '
|
||||
. 'Divi-Bibliothek.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -153,7 +153,7 @@ final class DiviOutlineAbility implements Ability
|
||||
return DiviErrors::unknownNode($rootId, $page->post);
|
||||
}
|
||||
|
||||
$outline = new Outline($this->design->read()->presetNames());
|
||||
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||||
$rows = $outline->rows(
|
||||
$page->index,
|
||||
$rootId,
|
||||
@@ -191,8 +191,12 @@ final class DiviOutlineAbility implements Ability
|
||||
|
||||
if (in_array(false, array_column($rows, 'editable'), true)) {
|
||||
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
||||
. 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
|
||||
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
|
||||
. 'verschieben, duplizieren oder löschen. '
|
||||
. ($this->loader->libraryIdsVisible()
|
||||
? 'Globale Module [global <ID>] im Bibliothekseintrag bearbeiten: lit-divi-outline mit post_id '
|
||||
. '<ID> – die Änderung wirkt auf alle Seiten.'
|
||||
// Ruling P13: without library access the entry stays unnamed.
|
||||
: 'Globale Module stammen aus der Divi-Bibliothek, für die dieses Konto keinen Zugang hat.');
|
||||
}
|
||||
|
||||
return $hints;
|
||||
|
||||
@@ -22,10 +22,23 @@ final class DiviPage
|
||||
public readonly WP_Post $post,
|
||||
public readonly Document $document,
|
||||
public readonly NodeIndex $index,
|
||||
private bool $libraryItem = false
|
||||
private bool $libraryItem = false,
|
||||
private ?string $themeBuilderArea = null
|
||||
) {
|
||||
}
|
||||
|
||||
/** Whether the entry is a Theme Builder layout (header, body or footer), loaded through ThemeBuilderAccess. */
|
||||
public function isThemeBuilderLayout(): bool
|
||||
{
|
||||
return $this->themeBuilderArea !== null;
|
||||
}
|
||||
|
||||
/** "header", "body" or "footer" for a Theme Builder layout, else null. */
|
||||
public function themeBuilderArea(): ?string
|
||||
{
|
||||
return $this->themeBuilderArea;
|
||||
}
|
||||
|
||||
/** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
|
||||
public function isLibraryItem(): bool
|
||||
{
|
||||
@@ -47,6 +60,11 @@ final class DiviPage
|
||||
|
||||
public function editUrl(): string
|
||||
{
|
||||
if ($this->themeBuilderArea !== null) {
|
||||
// Divi opens TB layouts from the Theme Builder screen (the builder URL needs its nonce).
|
||||
return (string) admin_url('admin.php?page=et_theme_builder');
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
||||
@@ -9,6 +9,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Log;
|
||||
@@ -18,7 +20,8 @@ use WP_Post;
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
|
||||
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), Theme Builder
|
||||
* layouts via ThemeBuilderAccess (Etappe 3b; hidden ones answer like a missing ID, P12), every other
|
||||
* entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
|
||||
* shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
|
||||
* is kept, never edited.
|
||||
@@ -31,7 +34,8 @@ final class DiviPageLoader
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private Parser $parser = new Parser(),
|
||||
private ?LibraryAccess $library = null
|
||||
private ?LibraryAccess $library = null,
|
||||
private ?ThemeBuilderAccess $themeBuilder = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,12 +45,19 @@ final class DiviPageLoader
|
||||
return $this->library;
|
||||
}
|
||||
|
||||
/** Ruling P13: whether the Divi tools may name library IDs (no library access = never). */
|
||||
public function libraryIdsVisible(): bool
|
||||
{
|
||||
return $this->library?->revealsIds() ?? false;
|
||||
}
|
||||
|
||||
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
|
||||
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
|
||||
{
|
||||
// Library entries (E1): with a library access, for reading and writing.
|
||||
// Library entries (E1) and Theme Builder layouts (Etappe 3b) through their access, everything else PostGuard.
|
||||
$item = $this->library?->item($postId, $forWrite);
|
||||
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||||
$layout = $item === null ? $this->themeBuilder?->layout($postId, $forWrite) : null;
|
||||
$post = $item ?? $layout ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||||
if ($post instanceof WP_Error) {
|
||||
return $post;
|
||||
}
|
||||
@@ -78,7 +89,9 @@ final class DiviPageLoader
|
||||
);
|
||||
}
|
||||
|
||||
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
|
||||
$area = $layout instanceof WP_Post ? DiviThemeBuilder::areaOf((string) $layout->post_type) : null;
|
||||
|
||||
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post, $area);
|
||||
}
|
||||
|
||||
/** The entry loaded for writing and checked by writable() – the refusal of either step. */
|
||||
@@ -93,14 +106,16 @@ final class DiviPageLoader
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional checks for writing: the type must use the Divi builder (library entries always do),
|
||||
* Additional checks for writing: the type must use the Divi builder (library entries and Theme Builder
|
||||
* layouts always do),
|
||||
* the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule
|
||||
* (spec §2).
|
||||
*/
|
||||
public function writable(DiviPage $page): ?WP_Error
|
||||
{
|
||||
$post = $page->post;
|
||||
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||||
$builderPost = $page->isLibraryItem() || $page->isThemeBuilderLayout();
|
||||
if (! $builderPost && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||||
return Errors::unsupported(
|
||||
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
|
||||
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
|
||||
|
||||
@@ -383,11 +383,12 @@ final class DiviReplaceTextAbility implements Ability
|
||||
'revision_id' => $saved['revision_id'] ?? null,
|
||||
'log_id' => $saved['log_id'] ?? null,
|
||||
'warnings' => is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [],
|
||||
];
|
||||
] + (isset($saved['restore_url']) ? ['restore_url' => $saved['restore_url']] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page commit() refused: its message, and for a global library entry the pages it shows on.
|
||||
* A page commit() refused: its message, and for a global library entry the pages it shows on (for a live
|
||||
* Theme Builder layout the templates).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -398,6 +399,16 @@ final class DiviReplaceTextAbility implements Ability
|
||||
return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.'];
|
||||
}
|
||||
|
||||
if (isset($data['templates'])) {
|
||||
// A layout of a live Theme Builder template (Etappe 3b): visible site-wide, like a global entry.
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => Errors::clientMessage($error) . ' Sichtbare Theme-Builder-Layouts nicht mit '
|
||||
. 'lit/divi-replace-text, sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
|
||||
'templates' => $data['templates'],
|
||||
] + (isset($data['hidden_templates']) ? ['hidden_templates' => $data['hidden_templates']] : []);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, '
|
||||
@@ -417,6 +428,11 @@ final class DiviReplaceTextAbility implements Ability
|
||||
*/
|
||||
private function hits(DiviPage $page, TextReplacer $replacer): array
|
||||
{
|
||||
return $this->search->replace($page, $replacer, $this->writer->composition($page)->cleaner());
|
||||
return $this->search->replace(
|
||||
$page,
|
||||
$replacer,
|
||||
$this->writer->composition($page)->cleaner(),
|
||||
$this->loader->libraryIdsVisible()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||||
use RuntimeException;
|
||||
@@ -52,7 +53,8 @@ defined('ABSPATH') || exit;
|
||||
* 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.
|
||||
* the static CSS of every page and the Theme Builder cache; a Theme Builder layout gets the Divi flags
|
||||
* and clears like a global entry.
|
||||
*
|
||||
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
|
||||
* Not final so abilities can be tested with a mock.
|
||||
@@ -105,16 +107,12 @@ class DiviSaver
|
||||
}
|
||||
FollowUp::run(
|
||||
static function () use ($postId, $scope): void {
|
||||
if (! class_exists('ET_Core_PageResource')) {
|
||||
return;
|
||||
if (class_exists('ET_Core_PageResource')) {
|
||||
\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');
|
||||
}
|
||||
DiviThemeBuilder::clearSiteCaches();
|
||||
}
|
||||
},
|
||||
'divi static css',
|
||||
|
||||
@@ -20,8 +20,9 @@ final class DiviSchema
|
||||
],
|
||||
'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.',
|
||||
'description' => 'Bei globalen Bibliothekseinträgen, bei Theme-Builder-Layouts aktiver Vorlagen (und '
|
||||
. 'beim Löschen): true erst, nachdem der Nutzer den Probelauf (confirm_required mit den '
|
||||
. 'betroffenen Seiten bzw. Vorlagen) bestätigt hat.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -68,14 +68,21 @@ final class DiviTextSearch
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hit of $page, computed without changing it.
|
||||
* Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library
|
||||
* access) the library IDs a global instance stores are no text to find: a global node is
|
||||
* matched without globalModule/globalParent, as if they were not stored; every other opaque
|
||||
* node (e.g. undecodable attributes) is matched as stored, the same with or without access.
|
||||
*
|
||||
* @return array{count: int, samples: list<array<string, string>>,
|
||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||
*/
|
||||
public function replace(DiviPage $page, TextReplacer $replacer, ValueCleaner $cleaner): array
|
||||
{
|
||||
public function replace(
|
||||
DiviPage $page,
|
||||
TextReplacer $replacer,
|
||||
ValueCleaner $cleaner,
|
||||
bool $globalIds = true
|
||||
): array {
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
$blocks = [];
|
||||
@@ -83,7 +90,10 @@ final class DiviTextSearch
|
||||
foreach ($page->index->entries() as $entry) {
|
||||
$block = $entry['block'];
|
||||
if ($block->isDivi() && $block->isOpaque()) {
|
||||
if ($replacer->mentions(Serializer::block($block))) {
|
||||
$markup = $globalIds || ! $block->isGlobal()
|
||||
? Serializer::block($block)
|
||||
: self::withoutLibraryIds($block);
|
||||
if ($replacer->mentions($markup)) {
|
||||
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||
}
|
||||
continue;
|
||||
@@ -112,6 +122,23 @@ final class DiviTextSearch
|
||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P13: the markup of the global node $block (decodable attributes, as isGlobal() needs)
|
||||
* with every globalModule/globalParent left out, at every depth – only for matching, never
|
||||
* saved. Openers are rebuilt from the decoded attributes, so Divi's raw spelling is not kept.
|
||||
*/
|
||||
private static function withoutLibraryIds(Block $block): string
|
||||
{
|
||||
$attrs = $block->attrs();
|
||||
unset($attrs['globalModule'], $attrs['globalParent']);
|
||||
$markup = '<!-- wp:' . $block->name() . ' ' . Serializer::attributes($attrs) . ' -->';
|
||||
foreach ($block->isVoid() ? [] : $block->parts() as $part) {
|
||||
$markup .= $part instanceof Block ? self::withoutLibraryIds($part) : $part;
|
||||
}
|
||||
|
||||
return $markup . '<!-- /wp:' . $block->name() . ' -->';
|
||||
}
|
||||
|
||||
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
||||
private static function opaqueReason(Block $block): string
|
||||
{
|
||||
|
||||
@@ -36,11 +36,18 @@ abstract class DiviWriteAbility implements Ability
|
||||
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.
|
||||
// made when revisions are off (also for live Theme Builder layouts, ruling Q2).
|
||||
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'affected_count' => ['type' => 'integer'],
|
||||
'hidden_count' => ['type' => 'integer'],
|
||||
'backup_id' => ['type' => 'integer'],
|
||||
// Library entries and Theme Builder layouts: their revision in WordPress' revision screen (audit L3 –
|
||||
// lit/revision-restore takes only content types).
|
||||
'restore_url' => ['type' => 'string'],
|
||||
// Theme Builder layouts: the enabled templates that show the layout – those the user may edit; the
|
||||
// others only as a count (ruling Q22).
|
||||
'templates' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'hidden_templates' => ['type' => 'integer'],
|
||||
], ['tree_rev']);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
use LIT\MCP\Integrations\Divi\Library\SimilarSections;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||
use WP_Error;
|
||||
|
||||
@@ -25,20 +27,27 @@ defined('ABSPATH') || exit;
|
||||
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
|
||||
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
|
||||
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point
|
||||
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup).
|
||||
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme
|
||||
* Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9). SimilarSections adds
|
||||
* the "similar section" warning (spec §5.5, E11) – not for lit/library-insert, whose sections come from the library.
|
||||
*/
|
||||
final class DiviWriter
|
||||
{
|
||||
public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit '
|
||||
. 'lit-divi-outline lesen.';
|
||||
|
||||
/** Inserts library entries: no advice to use the library (SimilarSections). */
|
||||
private const FROM_LIBRARY = 'lit/library-insert';
|
||||
|
||||
public function __construct(
|
||||
private DiviPageLoader $loader,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DesignSystemReader $design,
|
||||
private DiviComposition $composition,
|
||||
private ?GlobalEdits $globals = null
|
||||
private ?GlobalEdits $globals = null,
|
||||
private ?ThemeBuilderEdits $themeBuilder = null,
|
||||
private ?SimilarSections $similar = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -67,11 +76,12 @@ final class DiviWriter
|
||||
|
||||
/**
|
||||
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
|
||||
* references are checked against Divi's dynamic content options for it (ruling S22).
|
||||
* references are checked against Divi's dynamic content options for it (ruling S22); a Theme
|
||||
* Builder layout also by its area (F22: modules Divi renders only elsewhere are refused).
|
||||
*/
|
||||
public function composition(DiviPage $page): DiviComposition
|
||||
{
|
||||
return $this->composition->forPost((int) $page->post->ID);
|
||||
return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +112,8 @@ final class DiviWriter
|
||||
|
||||
$index = NodeIndex::build($page->document);
|
||||
$quality = $this->composition->warnings()->collect($index, $change->touched());
|
||||
$warnings = array_merge($change->warnings(), $quality);
|
||||
$similar = $ability === self::FROM_LIBRARY ? [] : ($this->similar?->warnings($change) ?? []);
|
||||
$warnings = array_merge($change->warnings(), $quality, $similar);
|
||||
|
||||
try {
|
||||
$saved = $this->saver->save($post, $content, $scope);
|
||||
@@ -116,7 +127,7 @@ final class DiviWriter
|
||||
$e->revisionId
|
||||
);
|
||||
}
|
||||
throw $e;
|
||||
throw self::withRestorePoint($page, $e);
|
||||
}
|
||||
|
||||
$outline = $this->outline($index, $change);
|
||||
@@ -144,14 +155,64 @@ final class DiviWriter
|
||||
foreach ($change->entries() as $entry) {
|
||||
$tool->changed(DiviChange::idOf($entry, $index) ?? '?', $entry['type'], $entry['action'], $entry['label']);
|
||||
}
|
||||
$restore = self::restoreUrl($page, $saved['revision_id']);
|
||||
if ($restore !== null) {
|
||||
$tool->with('restore_url', $restore);
|
||||
}
|
||||
|
||||
return $tool->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit L3: lit/revision-restore takes only the content types (ContentSchema) – not library entries or Theme
|
||||
* Builder layouts. Their revision is restored in WordPress' revision screen; null for everything else.
|
||||
*/
|
||||
private static function restoreUrl(DiviPage $page, mixed $revisionId): ?string
|
||||
{
|
||||
$noRestoreTool = $page->isLibraryItem() || $page->isThemeBuilderLayout();
|
||||
if (! $noRestoreTool || ! is_int($revisionId) || $revisionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return admin_url('revision.php?revision=' . $revisionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit L3 on the failure path: for a library entry or a Theme Builder layout the error names the WordPress
|
||||
* revision screen (restore_url) and says so in the hint of a partial write, instead of lit/revisions-list and
|
||||
* lit/revision-restore, which answer "not found" for these types. Pages keep the default hint.
|
||||
*/
|
||||
private static function withRestorePoint(DiviPage $page, WriteFailed $e): WriteFailed
|
||||
{
|
||||
if (! $page->isLibraryItem() && ! $page->isThemeBuilderLayout()) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
$restore = self::restoreUrl($page, $e->revisionId);
|
||||
$details = $restore === null ? [] : ['restore_url' => $restore];
|
||||
if ($e->rolledBack) {
|
||||
return $details === [] ? $e : $e->withDetails($details);
|
||||
}
|
||||
|
||||
$back = $restore === null
|
||||
? ''
|
||||
: sprintf(
|
||||
' Den vorherigen Stand (Revision %d) bei Bedarf im Revisionsvergleich von WordPress wiederherstellen '
|
||||
. '(restore_url).',
|
||||
(int) $e->revisionId
|
||||
);
|
||||
|
||||
return $e->withDetails(
|
||||
$details,
|
||||
'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit-divi-outline prüfen.' . $back
|
||||
. ' Dem Nutzer Bescheid geben.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* cache; the result then names the affected pages. Theme Builder layouts: commitLayout().
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
@@ -159,6 +220,10 @@ final class DiviWriter
|
||||
*/
|
||||
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||||
{
|
||||
if ($page->isThemeBuilderLayout()) {
|
||||
return $this->commitLayout($ability, $page, $change, $input);
|
||||
}
|
||||
|
||||
if (! $page->isLibraryItem()) {
|
||||
return $this->save($ability, $page, $change, $input);
|
||||
}
|
||||
@@ -179,48 +244,102 @@ final class DiviWriter
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags');
|
||||
}
|
||||
|
||||
/**
|
||||
* save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point
|
||||
* of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||||
{
|
||||
if ($this->themeBuilder === null) {
|
||||
return Errors::unsupported(
|
||||
'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.',
|
||||
'Das Layout im Divi Theme Builder bearbeiten.'
|
||||
);
|
||||
}
|
||||
|
||||
if (Serializer::document($page->document) === (string) $page->post->post_content) {
|
||||
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
|
||||
}
|
||||
|
||||
// E12: fields in a body checked against the templates' post types – warnings only, also in the dry run
|
||||
// and in any refusal (review M-12 a). The live templates are read once for both checks.
|
||||
$live = $this->themeBuilder->live();
|
||||
$fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change, $live);
|
||||
foreach ($fieldWarnings as $warning) {
|
||||
$change->warning($warning);
|
||||
}
|
||||
|
||||
$prepared = $this->themeBuilder->prepare($page, $input, $live);
|
||||
if ($prepared instanceof WP_Error) {
|
||||
return $fieldWarnings === [] ? $prepared : self::withWarnings($prepared, $fieldWarnings);
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
|
||||
}
|
||||
|
||||
/**
|
||||
* $error with the field warnings of the change (key warnings): a confirm_required also says so in its message.
|
||||
*
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private static function withWarnings(WP_Error $error, array $warnings): WP_Error
|
||||
{
|
||||
if (Errors::codeOf($error) === 'confirm_required') {
|
||||
return VisibleEdits::merged($error, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $warnings]);
|
||||
}
|
||||
|
||||
$data = $error->get_error_data();
|
||||
$data = is_array($data) ? $data : [];
|
||||
|
||||
return Errors::make(
|
||||
Errors::codeOf($error),
|
||||
Errors::clientMessage($error),
|
||||
(string) ($data['hint'] ?? ''),
|
||||
(int) ($data['status'] ?? 400),
|
||||
array_merge(array_diff_key($data, ['status' => true, 'hint' => true]), ['warnings' => $warnings])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a
|
||||
* failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @param array{scope: SaveScope, details: array<string, mixed>} $prepared
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function savePrepared(
|
||||
string $ability,
|
||||
DiviPage $page,
|
||||
DiviChange $change,
|
||||
array $input,
|
||||
array $prepared,
|
||||
string $entry
|
||||
): array {
|
||||
try {
|
||||
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
|
||||
} catch (WriteFailed $e) {
|
||||
throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null);
|
||||
throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry);
|
||||
}
|
||||
|
||||
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. */
|
||||
/** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
if ($page->isThemeBuilderLayout()) {
|
||||
return $this->themeBuilder?->notice($page, $error) ?? $error;
|
||||
}
|
||||
|
||||
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
|
||||
}
|
||||
|
||||
@@ -247,7 +366,7 @@ final class DiviWriter
|
||||
return ['text' => null, 'truncated' => false];
|
||||
}
|
||||
|
||||
$outline = new Outline($this->design->read()->presetNames());
|
||||
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||||
|
||||
return $outline->summary($index, array_map('strval', array_keys($sections)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The fields of a post type Divi can show as text through dynamic content (answer F4, E12): custom_meta_<key>
|
||||
* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys
|
||||
* never (S12), unknown types not (ruling P9) – each omitted with its reason. Choice fields are offered, but
|
||||
* Divi shows their stored value, not the label (stage3b render: "senior") – note(). Hidden fields (security
|
||||
* audit M4) are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check.
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class DynamicFields
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
public const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
public const UNKNOWN = 'kein bekanntes Feld dieses Inhaltstyps';
|
||||
|
||||
private const PREFIX = 'custom_meta_';
|
||||
|
||||
/** Divi's option for a meta key typed in by hand (the key is in settings.meta_key). */
|
||||
private const MANUAL = 'custom_meta_manual_custom_field_value';
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
|
||||
/** @var array<string, array{0: list<array<string, string>>, 1: list<array<string, string>>}> offered() by type. */
|
||||
private array $known = [];
|
||||
|
||||
/** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */
|
||||
public function __construct(private ContentSchema $schema, ?Closure $podStorage = null)
|
||||
{
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
}
|
||||
|
||||
/** @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields. */
|
||||
public function offered(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];
|
||||
}
|
||||
|
||||
/**
|
||||
* offered() once per post type for reason()/note() – they run per key and type of a check, and the default
|
||||
* storage callback loads the pod each time (review M-12 b).
|
||||
*
|
||||
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>}
|
||||
*/
|
||||
private function known(string $postType): array
|
||||
{
|
||||
return $this->known[$postType] ??= $this->offered($postType);
|
||||
}
|
||||
|
||||
/** Why {{field:$key}} would not show as text for $postType; null when it does. */
|
||||
public function reason(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields, $omitted] = $this->known($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
foreach ($omitted as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return $field['reason'];
|
||||
}
|
||||
}
|
||||
|
||||
return self::UNKNOWN;
|
||||
}
|
||||
|
||||
/** What to know about an offered field of $postType ($key) that shows other text than expected; else null. */
|
||||
public function note(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields] = $this->known($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key && $field['type'] === 'choice') {
|
||||
return 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta key the content reference $value reads, in both forms Divi resolves (ruling Q11, stage3b README
|
||||
* finding 12; DynamicContentOptionPostMetaKey.php:222-262): the name custom_meta_<key> (the plugin's form)
|
||||
* and the name post_meta_key with select_meta_key "custom_meta_<key>" (Divi's dialog), each also with a key
|
||||
* typed in by hand (custom_meta_manual_custom_field_value or post_meta_key with only meta_key). Null for
|
||||
* anything else. Reads, never validates – writing goes through VariableRef::canonical() (S29/S30).
|
||||
*/
|
||||
public static function metaKeyOf(string $value): ?string
|
||||
{
|
||||
if (VariableRef::typeOf($value) !== 'content') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = VariableRef::nameOf($value);
|
||||
$settings = VariableRef::settingsOf($value);
|
||||
$manual = is_string($settings['meta_key'] ?? null) ? $settings['meta_key'] : '';
|
||||
if ($name === 'post_meta_key') {
|
||||
$selected = $settings['select_meta_key'] ?? '';
|
||||
if (! is_string($selected)) {
|
||||
return null;
|
||||
}
|
||||
if ($selected === '') {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
$name = $selected;
|
||||
}
|
||||
|
||||
if ($name === null || ! str_starts_with($name, self::PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
if ($name === self::MANUAL) {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
|
||||
$key = str_replace(self::PREFIX, '', $name);
|
||||
|
||||
return $key !== '' ? $key : null;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Sections in short/full form composed into an empty document (lit/content-create, lit/tb-create-template): the
|
||||
* blocks come only from Composer (the single choke point, D7) and enter the document only through
|
||||
* TreeEditor::insert(), which wraps the first section in divi/placeholder (ruling S9). All or nothing.
|
||||
*/
|
||||
final class LayoutBuilder
|
||||
{
|
||||
/**
|
||||
* @param list<mixed> $sections
|
||||
* @param string $field Input path the errors are reported under (e.g. "divi", "body.nodes").
|
||||
* @return array{document: Document, blocks: list<Block>}|WP_Error
|
||||
*/
|
||||
public static function build(DiviComposition $composition, array $sections, string $field): array|WP_Error
|
||||
{
|
||||
if ($sections === []) {
|
||||
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
|
||||
$field => 'mindestens ein Abschnitt',
|
||||
]);
|
||||
}
|
||||
|
||||
$composed = $composition->composer()->compose(array_values($sections), null, $field);
|
||||
if ($composed['errors'] !== []) {
|
||||
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
$violation = $composition->editor()->insert(
|
||||
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
|
||||
$composed['blocks']
|
||||
);
|
||||
if ($violation !== null) {
|
||||
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
|
||||
$field => $violation,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['document' => $document, 'blocks' => $composed['blocks']];
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,16 @@ enum SaveScope
|
||||
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
|
||||
case GlobalItem;
|
||||
|
||||
/** A Theme Builder layout: Divi flags like the builder (finding 4); shown site-wide – all CSS, TB cache. */
|
||||
case ThemeBuilderLayout;
|
||||
|
||||
public function setsFlags(): bool
|
||||
{
|
||||
return $this === self::Page;
|
||||
return $this === self::Page || $this === self::ThemeBuilderLayout;
|
||||
}
|
||||
|
||||
public function clearsSiteCss(): bool
|
||||
{
|
||||
return $this === self::GlobalItem;
|
||||
return $this === self::GlobalItem || $this === self::ThemeBuilderLayout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The restore point and the confirm merge shared by every Divi write visitors see at once (answer F2
|
||||
* „vorher Revision/Sicherung“, ruling Q2): global or referenced library entries (GlobalEdits) and layouts
|
||||
* of live Theme Builder templates (ThemeBuilderEdits). Their own rules decide whether confirm is needed;
|
||||
* this class holds what is the same for both:
|
||||
* - the revision is DiviSaver's restore point; when revisions are off for the entry, a backup copy
|
||||
* (PostCloner, moved to the trash) is made after confirm and before the save – refused while it could
|
||||
* not be faithful (users without unfiltered_html: its wp_insert_post runs kses) or would not survive
|
||||
* (trash disabled) – ruling P9, S37;
|
||||
* - a failed save after that backup names it (ruling P14);
|
||||
* - a tool's own confirm_required (lit-divi-delete) is extended by the entry's question, so the one
|
||||
* confirm the client sends covers both.
|
||||
*/
|
||||
final class VisibleEdits
|
||||
{
|
||||
/** @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(?Closure $backup = null, ?Closure $trashEnabled = null)
|
||||
{
|
||||
$this->backup = $backup ?? static fn (WP_Post $post): int => (new PostCloner())->backup($post);
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. $label
|
||||
* names the entry in the message ("Bibliothekseintrag 12"), $hint says what to do instead.
|
||||
*/
|
||||
public function backupRefusal(WP_Post $post, string $label, string $hint): ?WP_Error
|
||||
{
|
||||
if (wp_revisions_enabled($post)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! current_user_can('unfiltered_html')) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für %s abgeschaltet, und die Sicherungskopie wäre für dieses Konto nicht '
|
||||
. 'verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.',
|
||||
$label
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für %s abgeschaltet, und der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0) '
|
||||
. '– die Sicherungskopie würde sofort gelöscht; nichts wurde gespeichert.',
|
||||
$label
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backup of $post when revisions are off for it (call backupRefusal() first): ['backup_id' => …],
|
||||
* else nothing – the revision DiviSaver takes is the restore point.
|
||||
*
|
||||
* @return array{backup_id?: int}
|
||||
* @throws WriteFailed When the backup could not be made (nothing was saved).
|
||||
*/
|
||||
public function backup(WP_Post $post): array
|
||||
{
|
||||
return wp_revisions_enabled($post) ? [] : ['backup_id' => ($this->backup)($post)];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own confirm_required $error extended by $note (appended to its message) and $details (the
|
||||
* entry's answer fields), keeping the tool's own fields.
|
||||
*
|
||||
* @param array<string, mixed> $details
|
||||
*/
|
||||
public static function merged(WP_Error $error, string $note, array $details): WP_Error
|
||||
{
|
||||
$data = $error->get_error_data();
|
||||
$own = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||||
|
||||
return Errors::confirmRequired(Errors::clientMessage($error) . ' ' . $note, array_merge($own, $details));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P14: a backup was made (revisions off) before the save failed – the error names it, because it
|
||||
* stays in the trash and is the only earlier state. $entry names what it saved ("des Bibliothekseintrags").
|
||||
*/
|
||||
public static function keepingBackup(WriteFailed $e, mixed $backupId, string $entry): WriteFailed
|
||||
{
|
||||
if (! is_int($backupId)) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
$kept = sprintf(
|
||||
'Sicherung %d liegt im Papierkorb (vorheriger Stand %s, in WordPress wiederherstellbar).',
|
||||
$backupId,
|
||||
$entry
|
||||
);
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,38 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
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).
|
||||
* {{link:<ID>}} and post_link_url_* targets from WordPress (rulings P10, Q14): the entry exists,
|
||||
* lives (no trash, auto-draft or revision), is no attachment (whatever its status), its type has a
|
||||
* Divi link option post_link_url_<type> (never the library et_pb_layout or an internal "_" type) and
|
||||
* the current user may read it (read_post, like select_post settings, S37). Missing, unreadable and
|
||||
* unlinkable entries give the same null (no existence or type oracle).
|
||||
*/
|
||||
final class WpLinkTargets implements LinkTargets
|
||||
{
|
||||
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
||||
|
||||
/** Never link targets, whatever Divi lists: attachments and the Divi Library (ruling Q14). */
|
||||
private const NEVER = ['attachment', 'et_pb_layout'];
|
||||
|
||||
/** @var Closure(string): bool */
|
||||
private Closure $linkable;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): bool)|null $linkable Whether Divi offers post_link_url_<type> (tests);
|
||||
* null = Divi's et_builder_get_public_post_types(), the list its link option uses.
|
||||
*/
|
||||
public function __construct(?Closure $linkable = null)
|
||||
{
|
||||
$this->linkable = $linkable ?? self::diviLinkable(...);
|
||||
}
|
||||
|
||||
public function postTypeOf(int $id): ?string
|
||||
{
|
||||
$post = get_post($id);
|
||||
@@ -25,6 +43,23 @@ final class WpLinkTargets implements LinkTargets
|
||||
return null;
|
||||
}
|
||||
|
||||
return current_user_can('read_post', $id) ? (string) $post->post_type : null;
|
||||
$type = (string) $post->post_type;
|
||||
if (in_array($type, self::NEVER, true) || str_starts_with($type, '_') || ! ($this->linkable)($type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return current_user_can('read_post', $id) ? $type : null;
|
||||
}
|
||||
|
||||
/** Fail closed: without Divi's list no type has a link option. */
|
||||
private static function diviLinkable(string $type): bool
|
||||
{
|
||||
if (! function_exists('et_builder_get_public_post_types')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$types = et_builder_get_public_post_types();
|
||||
|
||||
return is_array($types) && array_key_exists($type, $types);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -19,6 +20,11 @@ defined('ABSPATH') || exit;
|
||||
*/
|
||||
final class WpValueSanitizer implements ValueSanitizer
|
||||
{
|
||||
/** @param LinkTargets $links The {{link:}} targets post_link_url_* follow too (ruling P10). */
|
||||
public function __construct(private LinkTargets $links = new WpLinkTargets())
|
||||
{
|
||||
}
|
||||
|
||||
public function text(string $value): string
|
||||
{
|
||||
return sanitize_text_field($value);
|
||||
@@ -48,4 +54,9 @@ final class WpValueSanitizer implements ValueSanitizer
|
||||
{
|
||||
return current_user_can('read_post', $id);
|
||||
}
|
||||
|
||||
public function linkTargetType(int $id): ?string
|
||||
{
|
||||
return $this->links->postTypeOf($id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,24 @@ final class ModuleCatalog
|
||||
return array_values(array_unique(array_filter($children, 'is_string')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme Builder areas Divi renders the module in (appearance.themeBuilderLayouts, e.g. divi/post-content:
|
||||
* ["body"]; ModuleRegistration.php:509-527); null = no limit.
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function themeBuilderAreas(string $name): ?array
|
||||
{
|
||||
$areas = $this->modules[$name]['appearance']['themeBuilderLayouts'] ?? null;
|
||||
if (! is_array($areas)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$areas = array_values(array_filter($areas, 'is_string'));
|
||||
|
||||
return $areas === [] ? null : $areas;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function paths(string $name): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
|
||||
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing:
|
||||
* - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template()
|
||||
* (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first,
|
||||
* and while they run every wp_die handler throws instead (guarded(), defense in depth);
|
||||
* - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from
|
||||
* et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and
|
||||
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also
|
||||
* rewrite its default flag, title and onboarding meta.
|
||||
* Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts().
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class DiviThemeBuilder
|
||||
{
|
||||
public const TEMPLATE = 'et_template';
|
||||
|
||||
/** Post type of the Theme Builder posts (live, draft, library) that list the templates. */
|
||||
public const THEME_BUILDER = 'et_theme_builder';
|
||||
|
||||
/** Area => layout post type (theme-builder.php:7-23). */
|
||||
public const LAYOUT_TYPES = [
|
||||
'header' => 'et_header_layout',
|
||||
'body' => 'et_body_layout',
|
||||
'footer' => 'et_footer_layout',
|
||||
];
|
||||
|
||||
public const TEMPLATE_LIST = '_et_template';
|
||||
public const USE_ON = '_et_use_on';
|
||||
public const EXCLUDE_FROM = '_et_exclude_from';
|
||||
public const ENABLED = '_et_enabled';
|
||||
public const UNUSED = '_et_theme_builder_marked_as_unused';
|
||||
public const BACKUP_OPTION = 'et_tb_templates_backup_';
|
||||
|
||||
/** Divi functions this adapter calls; DiviCompatibility reports missing ones. */
|
||||
public const FUNCTIONS = [
|
||||
'et_theme_builder_get_theme_builder_post_id',
|
||||
'et_theme_builder_get_theme_builder_template_ids',
|
||||
'et_theme_builder_get_template',
|
||||
'et_theme_builder_insert_layout',
|
||||
'et_theme_builder_store_template',
|
||||
'et_theme_builder_get_flat_template_settings_options',
|
||||
'et_theme_builder_clear_wp_cache',
|
||||
'et_',
|
||||
];
|
||||
|
||||
private const DIE_FILTERS = [
|
||||
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
|
||||
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
|
||||
];
|
||||
|
||||
/** Actions of wp_insert_post() that name a post's ID after its row exists, earliest first (Q19). */
|
||||
private const INSERT_ACTIONS = ['clean_post_cache', 'save_post', 'wp_insert_post'];
|
||||
|
||||
/**
|
||||
* Every Theme Builder post type – the Theme Builder posts, the templates and the layouts (review M-3: one list for
|
||||
* GlobalUsages and lit/library-find-similar; ContentSchema keeps its own literal list in the WordPress layer).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function postTypes(): array
|
||||
{
|
||||
return array_merge([self::THEME_BUILDER, self::TEMPLATE], array_values(self::LAYOUT_TYPES));
|
||||
}
|
||||
|
||||
/** "header", "body" or "footer" for a layout post type, else null. */
|
||||
public static function areaOf(string $postType): ?string
|
||||
{
|
||||
$area = array_search($postType, self::LAYOUT_TYPES, true);
|
||||
|
||||
return is_string($area) ? $area : null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function missing(): array
|
||||
{
|
||||
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name)));
|
||||
}
|
||||
|
||||
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
|
||||
public function liveId(bool $create = false): int
|
||||
{
|
||||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||||
? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** The Theme Builder draft (auto-draft) of an unsaved Theme Builder session; 0 = none (F15). */
|
||||
public function draftId(): int
|
||||
{
|
||||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||||
? (int) et_theme_builder_get_theme_builder_post_id(false, false)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** @return list<int> Templates listed by Theme Builder post $themeBuilderId, in Divi's order (F15). */
|
||||
public function templateIds(int $themeBuilderId): array
|
||||
{
|
||||
if ($themeBuilderId <= 0 || ! function_exists('et_theme_builder_get_theme_builder_template_ids')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map(
|
||||
'intval',
|
||||
(array) et_theme_builder_get_theme_builder_template_ids(true, $themeBuilderId)
|
||||
));
|
||||
}
|
||||
|
||||
/** Template $id as Divi reads it, null when it is no template. */
|
||||
public function template(int $id, bool $attached): ?Template
|
||||
{
|
||||
if (! function_exists('et_theme_builder_get_template')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$divi = et_theme_builder_get_template($id);
|
||||
if (! is_array($divi) || $divi === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$marked = (string) get_post_meta($id, self::UNUSED, true);
|
||||
|
||||
return Template::fromDivi($divi, (string) get_post_status($id), $attached, $marked === '' ? null : $marked);
|
||||
}
|
||||
|
||||
/** @return list<Template> The templates of the live Theme Builder post. */
|
||||
public function liveTemplates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach ($this->templateIds($this->liveId()) as $id) {
|
||||
$template = $this->template($id, true);
|
||||
if ($template !== null) {
|
||||
$templates[] = $template;
|
||||
}
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published templates that no Theme Builder post lists (e.g. removed by a save in a Theme Builder
|
||||
* that was opened before they were created; Divi marks and later trashes them, F19).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function detachedIds(int $limit): array
|
||||
{
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- every _et_template row, live, draft and library TB posts.
|
||||
$listed = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
|
||||
self::TEMPLATE_LIST
|
||||
)));
|
||||
$ids = get_posts([
|
||||
'post_type' => self::TEMPLATE,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $limit,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'post__not_in' => $listed === [] ? [0] : $listed,
|
||||
]);
|
||||
|
||||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any Theme Builder post (live, draft or a Theme Builder library item) lists template $templateId –
|
||||
* the criterion detachedIds() uses; lit/tb-assign reattaches only templates none lists (audit L4).
|
||||
*/
|
||||
public function isListedAnywhere(int $templateId): bool
|
||||
{
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the _et_template rows of every Theme Builder post.
|
||||
$found = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
|
||||
self::TEMPLATE_LIST,
|
||||
(string) $templateId
|
||||
));
|
||||
|
||||
return $found !== null;
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function insertLayout(string $area, string $title): int
|
||||
{
|
||||
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
|
||||
$this->assertCanWrite('et_theme_builder_insert_layout');
|
||||
$id = $this->guarded(
|
||||
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title]),
|
||||
[$type]
|
||||
);
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
|
||||
throw new RuntimeException('et_theme_builder_insert_layout refused: ' . $reason);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new template through Divi's store_template (allow_default false) – always disabled and never the
|
||||
* default. Divi drops layout IDs that are no published layout of the area; callers read it back.
|
||||
*
|
||||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||||
* exclude_from: list<string>} $template
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function storeTemplate(int $themeBuilderId, array $template): int
|
||||
{
|
||||
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
|
||||
$payload = [
|
||||
'title' => $template['title'],
|
||||
'autogenerated_title' => '0',
|
||||
'default' => '0',
|
||||
'enabled' => '0',
|
||||
'layouts' => $template['layouts'],
|
||||
'use_on' => $template['use_on'],
|
||||
'exclude_from' => $template['exclude_from'],
|
||||
];
|
||||
$id = $this->guarded(
|
||||
static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false),
|
||||
[self::TEMPLATE]
|
||||
);
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
throw new RuntimeException('et_theme_builder_store_template stored no template');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends $templateId to the list of Theme Builder post $themeBuilderId in et_theme_builder_api_save()'s
|
||||
* order: the current list as backup option (readers use it meanwhile), the new row, backup removed.
|
||||
* Divi stores one string row per ID (stage3b theme-builder.json); an ID already listed stays listed once.
|
||||
* A Theme Builder editor opened before this call drops the row again on its next save and marks the
|
||||
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function attach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
|
||||
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
|
||||
return;
|
||||
}
|
||||
if (is_array($existing) && $existing !== []) {
|
||||
update_option(self::BACKUP_OPTION . $themeBuilderId, $existing);
|
||||
}
|
||||
$added = add_post_meta($themeBuilderId, self::TEMPLATE_LIST, $templateId);
|
||||
delete_option(self::BACKUP_OPTION . $themeBuilderId);
|
||||
if ($added === false) {
|
||||
throw new RuntimeException(
|
||||
sprintf('template %d not added to Theme Builder %d', $templateId, $themeBuilderId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function detach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one
|
||||
* row per unique condition.
|
||||
*
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
|
||||
{
|
||||
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
|
||||
delete_post_meta($templateId, $key);
|
||||
foreach (array_values(array_unique($conditions)) as $condition) {
|
||||
if (add_post_meta($templateId, $key, $condition) === false) {
|
||||
throw new RuntimeException(sprintf('%s of template %d not stored', $key, $templateId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function setEnabled(int $templateId, bool $enabled): void
|
||||
{
|
||||
$value = $enabled ? '1' : '0';
|
||||
update_post_meta($templateId, self::ENABLED, $value);
|
||||
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
|
||||
throw new RuntimeException(sprintf('_et_enabled of template %d not stored', $templateId));
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
|
||||
public function unmarkUnused(int $postId): void
|
||||
{
|
||||
delete_post_meta($postId, self::UNUSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's condition list (et_theme_builder_get_flat_template_settings_options()): ID => label and, for
|
||||
* IDs that end in ":" and take an object ID or slug, the object type and its value (e.g. post_type/page).
|
||||
*
|
||||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||||
*/
|
||||
public function flatConditions(): array
|
||||
{
|
||||
if (! function_exists('et_theme_builder_get_flat_template_settings_options')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$flat = [];
|
||||
foreach ((array) et_theme_builder_get_flat_template_settings_options() as $id => $setting) {
|
||||
if (! is_array($setting)) {
|
||||
continue;
|
||||
}
|
||||
$options = is_array($setting['options'] ?? null) ? $setting['options'] : [];
|
||||
$flat[(string) $id] = [
|
||||
'label' => wp_strip_all_tags((string) ($setting['label'] ?? $id)),
|
||||
'type' => is_string($options['type'] ?? null) ? $options['type'] : null,
|
||||
'value' => is_string($options['value'] ?? null) ? $options['value'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */
|
||||
public function clearCaches(): void
|
||||
{
|
||||
self::clearSiteCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* The site-wide clear shared by the Theme Builder tools and DiviSaver's site-wide scopes (review M-1): every
|
||||
* static CSS file and Divi's Theme Builder cache, each when Divi provides it.
|
||||
*/
|
||||
public static function clearSiteCaches(): void
|
||||
{
|
||||
if (class_exists('ET_Core_PageResource')) {
|
||||
\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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user
|
||||
* holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php:1464, 1644).
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function assertCanWrite(string ...$functions): void
|
||||
{
|
||||
if (! current_user_can('edit_others_posts')) {
|
||||
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
|
||||
}
|
||||
$missing = array_values(array_intersect($functions, $this->missing()));
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs $call with every wp_die handler replaced by one that throws (F17/F18) and with its output
|
||||
* discarded. Only this request's in-memory hooks change; the handler filters (added at PHP_INT_MAX
|
||||
* with a closure of this call) and every output buffer opened meanwhile are removed on every path.
|
||||
* Limit: when a third-party callback calls wp_die() inside a hook that Divi's wp_insert_post() fires,
|
||||
* the exception leaves that WP_Hook mid-iteration; its nesting level and $wp_current_filter are not
|
||||
* restored (WordPress offers no API for that), so later hooks of this request may see a stale filter
|
||||
* stack. The request ends with the tool's error anyway; the check before Divi is the primary defense.
|
||||
*
|
||||
* New posts of $types inserted meanwhile are watched (ruling Q19): wp_insert_post_data marks a pending insert
|
||||
* (not an update) of such a type, the first clean_post_cache/save_post/wp_insert_post for a post of that type
|
||||
* after it names its ID. When $call then throws, InsertedBeforeFailure carries those IDs so the caller removes
|
||||
* them too. Limit: a throw between the row insert and WordPress' first clean_post_cache() (default terms or
|
||||
* meta_input of a taxonomy/field registered for these types – none by default) leaves the ID unknown.
|
||||
*
|
||||
* @param list<string> $types
|
||||
* @throws RuntimeException InsertedBeforeFailure when posts were inserted before the failure.
|
||||
*/
|
||||
private function guarded(callable $call, array $types = []): mixed
|
||||
{
|
||||
$pending = [];
|
||||
$inserted = [];
|
||||
$data = static function (
|
||||
mixed $data,
|
||||
mixed $postarr = [],
|
||||
mixed $unsanitized = [],
|
||||
bool $update = false
|
||||
) use (
|
||||
$types,
|
||||
&$pending
|
||||
): mixed {
|
||||
$type = is_array($data) ? (string) ($data['post_type'] ?? '') : '';
|
||||
if (! $update && in_array($type, $types, true)) {
|
||||
$pending[$type] = ($pending[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $data;
|
||||
};
|
||||
$named = static function (mixed $postId, mixed $post = null) use (&$pending, &$inserted): void {
|
||||
$id = (int) $postId;
|
||||
$type = $post instanceof \WP_Post ? (string) $post->post_type : (string) get_post_type($id);
|
||||
if ($id > 0 && ($pending[$type] ?? 0) > 0 && ! in_array($id, $inserted, true)) {
|
||||
$inserted[] = $id;
|
||||
--$pending[$type];
|
||||
}
|
||||
};
|
||||
if ($types !== []) {
|
||||
add_filter('wp_insert_post_data', $data, PHP_INT_MIN, 4);
|
||||
foreach (self::INSERT_ACTIONS as $action) {
|
||||
add_action($action, $named, PHP_INT_MIN, 2);
|
||||
}
|
||||
}
|
||||
$handler = static function (): callable {
|
||||
return static function (mixed $message = ''): never {
|
||||
if ($message instanceof WP_Error) {
|
||||
$message = $message->get_error_message();
|
||||
}
|
||||
throw new RuntimeException(
|
||||
'Divi called wp_die(): ' . (is_string($message) && $message !== '' ? $message : 'without message')
|
||||
);
|
||||
};
|
||||
};
|
||||
foreach (self::DIE_FILTERS as $filter) {
|
||||
add_filter($filter, $handler, PHP_INT_MAX);
|
||||
}
|
||||
$level = ob_get_level();
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
return $call();
|
||||
} catch (Throwable $e) {
|
||||
if ($inserted !== []) {
|
||||
throw new InsertedBeforeFailure($e->getMessage(), $inserted, $e);
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
while (ob_get_level() > $level) {
|
||||
ob_end_clean();
|
||||
}
|
||||
foreach (self::DIE_FILTERS as $filter) {
|
||||
remove_filter($filter, $handler, PHP_INT_MAX);
|
||||
}
|
||||
if ($types !== []) {
|
||||
remove_filter('wp_insert_post_data', $data, PHP_INT_MIN);
|
||||
foreach (self::INSERT_ACTIONS as $action) {
|
||||
remove_action($action, $named, PHP_INT_MIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Divi failed after its own wp_insert_post() already created posts (a throwing hook, a wp_die() turned into an
|
||||
* exception by DiviThemeBuilder::guarded()): $postIds are the new posts of the expected types, so the caller can
|
||||
* roll them back (ruling Q19). The message is the original failure's.
|
||||
*/
|
||||
final class InsertedBeforeFailure extends RuntimeException
|
||||
{
|
||||
/** @param list<int> $postIds */
|
||||
public function __construct(string $message, public readonly array $postIds, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/tb-activate (answer F1): the one place where LIT MCP makes a template visible to visitors or hides it
|
||||
* again – _et_enabled, nothing else. Always a dry run first (conditions with labels, layouts, identical and
|
||||
* possible overlaps with other live templates – ruling Q8/B9), then confirm: true; both directions, every switch
|
||||
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
|
||||
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
|
||||
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
|
||||
* published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with
|
||||
* DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21).
|
||||
*/
|
||||
final class TbActivateAbility implements Ability
|
||||
{
|
||||
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
|
||||
|
||||
/** Ruling Q20: the off-switch works despite a Theme Builder draft – which may restore its own state. */
|
||||
public const DRAFT_WARNING = 'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); wird ein dort '
|
||||
. 'geöffnetes Fenster gespeichert, stellt Divi evtl. seinen eigenen Stand wieder her und die Vorlage ist '
|
||||
. 'wieder aktiv – danach mit lit/tb-list prüfen oder die Änderungen im Theme Builder verwerfen.';
|
||||
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private TemplateConditions $conditions,
|
||||
private ChangeLog $log
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/tb-activate';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Theme-Builder-Vorlage aktivieren';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Aktiviert (enabled: true) oder deaktiviert (enabled: false) eine Vorlage des Divi Theme Builders – die '
|
||||
. 'einzige Stelle, an der LIT MCP eine Vorlage für Besucher sichtbar macht. Immer zuerst Probelauf '
|
||||
. '(Bedingungen, Layouts, Überschneidungen mit anderen aktiven Vorlagen), dann nach Rückfrage beim Nutzer '
|
||||
. 'confirm: true. Deaktivieren ist der schnelle Weg zurück. template_rev aus lit/tb-list ist Pflicht; die '
|
||||
. 'Standardvorlage nur im Theme Builder.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
|
||||
'enabled' => ['type' => 'boolean'],
|
||||
'confirm' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['template_id', 'template_rev', 'enabled'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'template_id' => ['type' => 'integer'],
|
||||
'template_rev' => ['type' => 'string'],
|
||||
'enabled' => ['type' => 'boolean'],
|
||||
'live' => ['type' => 'boolean'],
|
||||
], ['template_id', 'template_rev', 'enabled', 'live']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$template = $this->access->changeableTemplate($input, ($input['enabled'] ?? null) === false);
|
||||
|
||||
return $template instanceof WP_Error ? $template : true;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed */
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$enabled = $input['enabled'] ?? null;
|
||||
$template = $this->access->changeableTemplate($input, $enabled === false);
|
||||
if ($template instanceof WP_Error) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
if (! is_bool($enabled)) {
|
||||
return Errors::invalidInput('enabled fehlt; nichts wurde gespeichert.', ['enabled' => 'true oder false']);
|
||||
}
|
||||
|
||||
if (! $template->attached) {
|
||||
return Errors::invalidInput(
|
||||
sprintf('Vorlage %d ist im Theme Builder nicht zugeordnet; nichts wurde gespeichert.', $template->id),
|
||||
['template_id' => 'zuerst mit lit/tb-assign und reattach: true wieder zuordnen']
|
||||
);
|
||||
}
|
||||
|
||||
if ($template->enabled === $enabled) {
|
||||
return $this->result($template, sprintf(
|
||||
'Keine Änderung – die Vorlage „%s“ ist bereits %s.',
|
||||
$template->title,
|
||||
$enabled ? 'aktiv' : 'deaktiviert'
|
||||
), null);
|
||||
}
|
||||
|
||||
if ($enabled) {
|
||||
$refusal = $this->activationRefusal($template);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
}
|
||||
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return $this->dryRun($template, $enabled);
|
||||
}
|
||||
|
||||
return $this->switch($template, $enabled);
|
||||
}
|
||||
|
||||
/** Enabling needs conditions and published layouts (B10). */
|
||||
private function activationRefusal(Template $template): ?WP_Error
|
||||
{
|
||||
if ($template->useOn === []) {
|
||||
return Errors::invalidInput(
|
||||
'Ohne Bedingungen gilt eine Vorlage nirgends; zuerst mit lit/tb-assign Bedingungen setzen. Nichts '
|
||||
. 'wurde gespeichert.',
|
||||
['template_id' => 'keine Bedingungen (use_on)']
|
||||
);
|
||||
}
|
||||
|
||||
return $this->access->layoutsRefusal($template);
|
||||
}
|
||||
|
||||
private function dryRun(Template $template, bool $enabled): WP_Error
|
||||
{
|
||||
$labels = array_map($this->conditions->label(...), $template->useOn);
|
||||
$message = $enabled
|
||||
? sprintf(
|
||||
'Aktivieren: Die Vorlage „%s“ gilt danach sofort für Besucher (%s). Überschneidungen mit anderen '
|
||||
. 'aktiven Vorlagen unter overlaps/possible_overlaps. Nichts wurde gespeichert (Probelauf).',
|
||||
$template->title,
|
||||
implode('; ', $labels)
|
||||
)
|
||||
: sprintf(
|
||||
'Deaktivieren: Die Vorlage „%s“%s gilt danach nicht mehr (%s); dort zeigt Divi wieder eine andere '
|
||||
. 'Vorlage, die Standardvorlage oder das Theme. Nichts wurde gespeichert (Probelauf).',
|
||||
$template->title,
|
||||
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
|
||||
implode('; ', $labels)
|
||||
);
|
||||
$overlaps = Template::overlapDetails(
|
||||
$enabled ? $this->access->editableLiveTemplates() : ['templates' => [], 'hidden' => []],
|
||||
$template->id,
|
||||
$template->useOn
|
||||
);
|
||||
$message .= Template::hiddenOverlapsNote($overlaps['hidden_overlaps']);
|
||||
if (! $enabled && $this->draftPending()) {
|
||||
$message .= ' ' . self::DRAFT_WARNING;
|
||||
}
|
||||
|
||||
return Errors::confirmRequired($message, [
|
||||
'use_on' => $labels,
|
||||
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
|
||||
'layouts' => $template->layouts,
|
||||
] + $overlaps);
|
||||
}
|
||||
|
||||
private function draftPending(): bool
|
||||
{
|
||||
return $this->access->store()->draftId() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function switch(Template $template, bool $enabled): array
|
||||
{
|
||||
$store = $this->access->store();
|
||||
try {
|
||||
$store->setEnabled($template->id, $enabled);
|
||||
} catch (Throwable $e) {
|
||||
$this->undo($template, $e);
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
FollowUp::run(
|
||||
static fn () => $store->clearCaches(),
|
||||
'theme builder cache',
|
||||
'Der Cache des Theme Builders konnte nicht geleert werden; die Änderung ist gespeichert, wirkt aber evtl. '
|
||||
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||||
$warnings
|
||||
);
|
||||
if (! $enabled && $this->draftPending()) {
|
||||
$warnings[] = self::DRAFT_WARNING;
|
||||
}
|
||||
$logId = $this->log->record(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $template->id,
|
||||
sprintf(
|
||||
'Theme Builder: Vorlage „%s“ %s (Bedingungen: %s)',
|
||||
$template->title,
|
||||
$enabled ? 'aktiviert' : 'deaktiviert',
|
||||
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
|
||||
)
|
||||
);
|
||||
$after = $store->template($template->id, true);
|
||||
|
||||
$summary = $enabled
|
||||
? sprintf(
|
||||
'Vorlage „%s“ aktiviert – sie gilt jetzt für Besucher. Schneller Weg zurück: lit/tb-activate mit '
|
||||
. 'enabled: false (template_rev aus dieser Antwort).',
|
||||
$template->title
|
||||
)
|
||||
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
|
||||
|
||||
return $this->result($after ?? $template, $summary, $logId, $enabled, $after?->isLive() ?? $enabled, $warnings);
|
||||
}
|
||||
|
||||
/** @throws WriteFailed Always. */
|
||||
private function undo(Template $template, Throwable $cause): never
|
||||
{
|
||||
$store = $this->access->store();
|
||||
$failures = Rollback::attempt(static fn () => $store->setEnabled($template->id, $template->enabled));
|
||||
if ($failures === []) {
|
||||
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-activate failed: ' . $cause->getMessage());
|
||||
}
|
||||
|
||||
$this->log->recordPartial(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $template->id,
|
||||
sprintf('Theme Builder: Umschalten von „%s“ nicht zurückgenommen (%s)', $template->title, $failures[0])
|
||||
);
|
||||
throw WriteFailed::partial(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'tb-activate rollback: ' . $failures[0],
|
||||
$template->id,
|
||||
hint: 'Mit lit/tb-list prüfen, ob die Vorlage aktiv ist.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $warnings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(
|
||||
Template $template,
|
||||
string $summary,
|
||||
?int $logId,
|
||||
?bool $enabled = null,
|
||||
?bool $live = null,
|
||||
array $warnings = []
|
||||
): array {
|
||||
return ToolResult::ok($summary)
|
||||
->with('template_id', $template->id)
|
||||
->with('template_rev', $template->rev())
|
||||
->with('enabled', $enabled ?? $template->enabled)
|
||||
->with('live', $live ?? $template->isLive())
|
||||
->warnings($warnings)
|
||||
->logId($logId)
|
||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/tb-assign (spec §5.7): replaces the conditions of a template (_et_use_on/_et_exclude_from, only from
|
||||
* TemplateConditions). use_on is required; an omitted exclude_from stays as it is, an explicit [] clears it
|
||||
* (ruling Q3/B3). A live template changes the website at once, so it is a dry run first and needs confirm: true
|
||||
* (ruling Q9/B22), listing both lists before/after and overlaps (identical and possible, B9) with other live
|
||||
* templates for the resulting lists. reattach: true puts a template a stale Theme Builder save removed back on the
|
||||
* live list (only on the user's request, B19; only one no Theme Builder post lists – tb-list's detached, audit L4)
|
||||
* – always disabled, Divi's "unused" marks removed from it and its
|
||||
* layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the
|
||||
* answer and the change log; a failed write restores them (and undoes the reattach). The undo does not put back
|
||||
* Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer
|
||||
* trashes it after seven days – harmless, it stays under detached in lit/tb-list.
|
||||
*/
|
||||
final class TbAssignAbility implements Ability
|
||||
{
|
||||
public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.';
|
||||
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private TemplateConditions $conditions,
|
||||
private ChangeLog $log
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/tb-assign';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Theme-Builder-Vorlage zuordnen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Setzt die Bedingungen einer Theme-Builder-Vorlage neu: use_on ersetzt die bisherigen (z. B. '
|
||||
. '"singular:post_type:person:all" = alle Einträge des Inhaltstyps person), exclude_from ebenso – fehlt '
|
||||
. 'exclude_from, bleiben die Ausnahmen, [] entfernt sie. IDs aus lit/tb-list mit conditions: true; '
|
||||
. 'template_rev aus lit/tb-list ist Pflicht. Bei einer aktiven Vorlage wirkt das sofort auf der Website: '
|
||||
. 'erst Probelauf (vorher/nachher, Überschneidungen), dann nach Rückfrage beim Nutzer confirm: true. '
|
||||
. 'reattach: true ordnet eine Vorlage wieder zu, die ein Speichern im Theme Builder entfernt hat (nur auf '
|
||||
. 'ausdrücklichen Wunsch des Nutzers; sie bleibt deaktiviert). Die Standardvorlage nur im Theme Builder.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
|
||||
'use_on' => $conditions,
|
||||
'exclude_from' => $conditions + [
|
||||
'description' => 'Fehlt es, bleiben die bisherigen Ausnahmen; [] entfernt sie.',
|
||||
],
|
||||
'reattach' => ['type' => 'boolean'],
|
||||
'confirm' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['template_id', 'template_rev', 'use_on'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'template_id' => ['type' => 'integer'],
|
||||
'template_rev' => ['type' => 'string'],
|
||||
'live' => ['type' => 'boolean'],
|
||||
'previous' => ['type' => 'object'],
|
||||
], ['template_id', 'template_rev', 'live']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$template = $this->access->changeableTemplate($input);
|
||||
|
||||
return $template instanceof WP_Error ? $template : true;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed */
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$template = $this->access->changeableTemplate($input);
|
||||
if ($template instanceof WP_Error) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$useOn = is_array($input['use_on'] ?? null)
|
||||
? $this->conditions->check($input['use_on'], 'use_on')
|
||||
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
|
||||
$excludeFrom = match (true) {
|
||||
! array_key_exists('exclude_from', $input) => ['ids' => $template->excludeFrom, 'errors' => []],
|
||||
is_array($input['exclude_from']) => $this->conditions->check($input['exclude_from'], 'exclude_from'),
|
||||
default => [
|
||||
'ids' => [],
|
||||
'errors' => ['exclude_from' => 'Liste von Bedingungs-IDs erwartet ([] entfernt die Ausnahmen)'],
|
||||
],
|
||||
};
|
||||
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
|
||||
$reattach = ! $template->attached && ($input['reattach'] ?? false) === true;
|
||||
$refusal = $this->reattachRefusal($template, $reattach);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
if ($template->isLive() && ($input['confirm'] ?? false) !== true) {
|
||||
return $this->dryRun($template, $useOn['ids'], $excludeFrom['ids']);
|
||||
}
|
||||
|
||||
return $this->write($template, $useOn['ids'], $excludeFrom['ids'], $reattach);
|
||||
}
|
||||
|
||||
/**
|
||||
* A detached template needs reattach: true, a live Theme Builder post and published layouts (B10). Detached
|
||||
* means what tb-list shows as detached: no Theme Builder post lists it – a template of a Theme Builder library
|
||||
* item or draft is none (audit L4).
|
||||
*/
|
||||
private function reattachRefusal(Template $template, bool $reattach): ?WP_Error
|
||||
{
|
||||
if ($template->attached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->access->store()->isListedAnywhere($template->id)) {
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Vorlage %d ist keine entfernte Vorlage (siehe detached in lit/tb-list); nichts wurde gespeichert.',
|
||||
$template->id
|
||||
),
|
||||
[
|
||||
'template_id' => 'nur Vorlagen der Website (templates) oder entfernte Vorlagen (detached) aus '
|
||||
. 'lit/tb-list',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (! $reattach) {
|
||||
$marked = $template->markedUnused === null
|
||||
? ''
|
||||
: sprintf(' (seit %s als ungenutzt markiert; Divi löscht sie 7 Tage danach)', $template->markedUnused);
|
||||
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Vorlage %d ist im Theme Builder nicht mehr zugeordnet%s; nichts wurde gespeichert.',
|
||||
$template->id,
|
||||
$marked
|
||||
),
|
||||
[
|
||||
'reattach' => 'nur auf Wunsch des Nutzers mit reattach: true wieder zuordnen (die Vorlage bleibt '
|
||||
. 'deaktiviert)',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->access->store()->liveId() <= 0) {
|
||||
return Errors::unsupported(
|
||||
'Der Divi Theme Builder wurde noch nie gespeichert; es gibt keine Liste, der die Vorlage wieder '
|
||||
. 'zugeordnet werden kann. Nichts wurde gespeichert.',
|
||||
'Den Theme Builder einmal speichern (Divi → Theme Builder), dann erneut aufrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->access->layoutsRefusal($template);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
*/
|
||||
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
|
||||
{
|
||||
$overlaps = Template::overlapDetails($this->access->editableLiveTemplates(), $template->id, $useOn);
|
||||
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf '
|
||||
. 'der Website – vorher/nachher unter before/after.%s Nichts wurde gespeichert (Probelauf).',
|
||||
$template->title,
|
||||
Template::hiddenOverlapsNote($overlaps['hidden_overlaps'])
|
||||
),
|
||||
[
|
||||
'before' => $this->labels($template->useOn, $template->excludeFrom),
|
||||
'after' => $this->labels($useOn, $excludeFrom),
|
||||
] + $overlaps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @return array{use_on: list<string>, exclude_from: list<string>}
|
||||
*/
|
||||
private function labels(array $useOn, array $excludeFrom): array
|
||||
{
|
||||
return [
|
||||
'use_on' => array_map($this->conditions->label(...), $useOn),
|
||||
'exclude_from' => array_map($this->conditions->label(...), $excludeFrom),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function write(Template $template, array $useOn, array $excludeFrom, bool $reattach): array
|
||||
{
|
||||
$store = $this->access->store();
|
||||
$themeBuilderId = $store->liveId();
|
||||
try {
|
||||
if ($reattach) {
|
||||
$store->setEnabled($template->id, false);
|
||||
$store->attach($themeBuilderId, $template->id);
|
||||
foreach (array_merge([$template->id], array_column($template->layouts, 'id')) as $id) {
|
||||
if ($id > 0) {
|
||||
$store->unmarkUnused($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
$store->setConditions($template->id, $useOn, $excludeFrom);
|
||||
} catch (Throwable $e) {
|
||||
$this->undo($template, $reattach, $themeBuilderId, $e);
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
if ($template->isLive()) {
|
||||
FollowUp::run(
|
||||
static fn () => $store->clearCaches(),
|
||||
'theme builder cache',
|
||||
'Der Cache des Theme Builders konnte nicht geleert werden; die Bedingungen sind gespeichert, wirken '
|
||||
. 'aber evtl. erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||||
$warnings
|
||||
);
|
||||
}
|
||||
if ($reattach) {
|
||||
$warnings[] = TbCreateTemplateAbility::STALE_EDITOR;
|
||||
}
|
||||
$logId = $this->log->record(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $template->id,
|
||||
sprintf(
|
||||
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
|
||||
$template->title,
|
||||
$reattach
|
||||
? sprintf(
|
||||
', wieder zugeordnet (deaktiviert, vorher %s)',
|
||||
$template->enabled ? 'aktiviert' : 'deaktiviert'
|
||||
)
|
||||
: '',
|
||||
self::listed($template->useOn),
|
||||
self::listed($template->excludeFrom)
|
||||
)
|
||||
);
|
||||
$after = $store->template($template->id, $template->attached || $reattach);
|
||||
$live = $after?->isLive() ?? false;
|
||||
|
||||
return ToolResult::ok(sprintf(
|
||||
'Bedingungen der Vorlage „%s“ gesetzt%s%s.',
|
||||
$template->title,
|
||||
$reattach ? ' und wieder zugeordnet' : '',
|
||||
$live
|
||||
? ' – wirkt sofort auf der Website; zurück mit lit/tb-assign und den Bedingungen unter previous, '
|
||||
. 'ausblenden mit lit/tb-activate (enabled: false)'
|
||||
: ' (Vorlage deaktiviert, ohne Wirkung auf die Website)'
|
||||
))
|
||||
->with('template_id', $template->id)
|
||||
->with('template_rev', $after?->rev() ?? '')
|
||||
->with('live', $live)
|
||||
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]
|
||||
+ ($reattach ? ['enabled' => $template->enabled] : []))
|
||||
->warnings($warnings)
|
||||
->logId($logId)
|
||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param list<string> $conditions */
|
||||
private static function listed(array $conditions): string
|
||||
{
|
||||
return $conditions === [] ? 'keine' : implode(', ', $conditions);
|
||||
}
|
||||
|
||||
/** @throws WriteFailed Always. */
|
||||
private function undo(Template $template, bool $reattach, int $themeBuilderId, Throwable $cause): never
|
||||
{
|
||||
$store = $this->access->store();
|
||||
$steps = [static fn () => $store->setConditions($template->id, $template->useOn, $template->excludeFrom)];
|
||||
if ($reattach) {
|
||||
$steps[] = static fn () => $store->detach($themeBuilderId, $template->id);
|
||||
$steps[] = static fn () => $store->setEnabled($template->id, $template->enabled);
|
||||
}
|
||||
|
||||
$failures = Rollback::attempt(...$steps);
|
||||
if ($failures === []) {
|
||||
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-assign failed: ' . $cause->getMessage());
|
||||
}
|
||||
|
||||
$this->log->recordPartial(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $template->id,
|
||||
sprintf(
|
||||
'Theme Builder: Bedingungen von „%s“ nur teilweise zurückgenommen (%s)',
|
||||
$template->title,
|
||||
implode('; ', $failures)
|
||||
)
|
||||
);
|
||||
throw WriteFailed::partial(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'tb-assign rollback: ' . implode('; ', $failures),
|
||||
$template->id,
|
||||
hint: sprintf(
|
||||
'Mit lit/tb-list prüfen; vorher galt: %s; Ausnahmen: %s.',
|
||||
self::listed($template->useOn),
|
||||
self::listed($template->excludeFrom)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/tb-create-template (spec §5.7, E7, answer F1): a Theme Builder template, ALWAYS disabled, with per area a
|
||||
* new layout (optionally with nodes – Composer/TreeEditor, stored by DiviSaver::saveNew, D7), the default
|
||||
* template's layout ("global" header/footer), nothing of its own ("default") or the area hidden. Conditions only
|
||||
* from TemplateConditions. Everything is validated first; Divi then creates layouts and template
|
||||
* (DiviThemeBuilder), the template is read back and appended to the live list; any failure removes all of it
|
||||
* (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7). Never enables anything (F1, Q5):
|
||||
* Divi stores it with enabled '0' and a template that reads back enabled or as default is rolled back.
|
||||
*/
|
||||
final class TbCreateTemplateAbility implements Ability
|
||||
{
|
||||
public const SOURCES = ['new', 'global', 'default', 'hidden'];
|
||||
|
||||
public const STALE_EDITOR = 'Ist der Divi Theme Builder gerade in einem anderen Fenster geöffnet, dort vor dem '
|
||||
. 'nächsten Speichern neu laden – sonst entfernt Divi die neue Vorlage wieder aus der Liste (lit/tb-list zeigt '
|
||||
. 'sie dann unter detached).';
|
||||
|
||||
private const AREA_LABELS = ['header' => 'Kopfzeile', 'body' => 'Inhalt', 'footer' => 'Fußzeile'];
|
||||
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private TemplateConditions $conditions,
|
||||
private DiviComposition $composition,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private ?ThemeBuilderEdits $edits = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/tb-create-template';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Theme-Builder-Vorlage anlegen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt eine Vorlage im Divi Theme Builder an – immer deaktiviert, also ohne Wirkung auf die Website. Je '
|
||||
. 'Bereich (header, body, footer) source "new" (neues Layout, optional mit nodes: Abschnitte in Kurz- oder '
|
||||
. 'Vollform wie bei lit-divi-insert, z. B. {{post:title}} oder {{field:<Feld>}}), "global" (Kopf-/Fußzeile '
|
||||
. 'der Standardvorlage mitbenutzen), "default" (nichts Eigenes) oder "hidden" (Bereich ausblenden). '
|
||||
. 'Bedingungen optional (use_on, exclude_from; IDs aus lit/tb-list mit conditions: true). Aktivieren '
|
||||
. 'nur mit lit/tb-activate.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
$area = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'source' => ['type' => 'string', 'enum' => self::SOURCES],
|
||||
'title' => ['type' => 'string', 'maxLength' => 200, 'description' => 'Titel des neuen Layouts.'],
|
||||
'nodes' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => 20,
|
||||
'items' => ['type' => 'object'],
|
||||
'description' => 'Nur mit source "new": Abschnitte wie bei lit-divi-insert.',
|
||||
],
|
||||
],
|
||||
'required' => ['source'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||||
'header' => $area,
|
||||
'body' => $area,
|
||||
'footer' => $area,
|
||||
'use_on' => $conditions,
|
||||
'exclude_from' => $conditions,
|
||||
],
|
||||
'required' => ['title'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'template_id' => ['type' => 'integer'],
|
||||
'template_rev' => ['type' => 'string'],
|
||||
'enabled' => ['type' => 'boolean'],
|
||||
'layouts' => ['type' => 'object'],
|
||||
], ['template_id', 'template_rev', 'enabled']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::write(false);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed After the first post exists (everything was rolled back first, if possible). */
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = $this->access->canCreate() ?? $this->access->draftRefusal();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||||
if ($title === '') {
|
||||
$errors['title'] = 'nicht leer';
|
||||
}
|
||||
$plan = $this->plan($input, $title, $errors);
|
||||
$useOn = $this->conditions->check($input['use_on'] ?? null, 'use_on');
|
||||
$excludeFrom = $this->conditions->check($input['exclude_from'] ?? null, 'exclude_from');
|
||||
$errors = array_merge($errors, $useOn['errors'], $excludeFrom['errors']);
|
||||
foreach ($plan as $area => $spec) {
|
||||
if ($spec['nodes'] !== []) {
|
||||
$layout = LayoutBuilder::build($this->composition->forPost(0, $area), $spec['nodes'], $area . '.nodes');
|
||||
if ($layout instanceof WP_Error) {
|
||||
$errors = array_merge($errors, self::fields($layout, $area));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Die Vorlage ist ungültig; nichts wurde angelegt.', $errors);
|
||||
}
|
||||
|
||||
return $this->create($title, $plan, $useOn['ids'], $excludeFrom['ids']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, string> $errors
|
||||
* @return array<string, array{source: string, title: string, nodes: list<mixed>, id: int}>
|
||||
*/
|
||||
private function plan(array $input, string $title, array &$errors): array
|
||||
{
|
||||
$default = null;
|
||||
$plan = [];
|
||||
foreach (Template::AREAS as $area) {
|
||||
$spec = is_array($input[$area] ?? null) ? $input[$area] : ['source' => 'default'];
|
||||
$source = $spec['source'] ?? null;
|
||||
if (! in_array($source, self::SOURCES, true)) {
|
||||
$errors[$area . '.source'] = 'new, global, default oder hidden';
|
||||
continue;
|
||||
}
|
||||
|
||||
$nodes = is_array($spec['nodes'] ?? null) ? array_values($spec['nodes']) : [];
|
||||
if ($nodes !== [] && $source !== 'new') {
|
||||
$errors[$area . '.nodes'] = 'nur mit source "new"';
|
||||
}
|
||||
|
||||
$id = 0;
|
||||
if ($source === 'global') {
|
||||
if ($area === 'body') {
|
||||
$errors['body.source'] = 'für den Inhalt gibt es kein globales Layout';
|
||||
continue;
|
||||
}
|
||||
$default ??= $this->defaultTemplate();
|
||||
$id = $this->reusable($area, $default);
|
||||
if ($id === null) {
|
||||
$errors[$area . '.source'] = sprintf(
|
||||
'die Standardvorlage hat keine eigene %s, die sich mitbenutzen lässt',
|
||||
self::AREA_LABELS[$area]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$layoutTitle = sanitize_text_field(is_string($spec['title'] ?? null) ? $spec['title'] : '');
|
||||
$plan[$area] = [
|
||||
'source' => (string) $source,
|
||||
'title' => $layoutTitle !== '' ? $layoutTitle : sprintf('%s – %s', $title, self::AREA_LABELS[$area]),
|
||||
'nodes' => $nodes,
|
||||
'id' => $id,
|
||||
];
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11) – from the one shared
|
||||
* split of the live templates (ThemeBuilderAccess::editableLiveTemplates(), ruling Q21, review M-11).
|
||||
*/
|
||||
private function defaultTemplate(): ?Template
|
||||
{
|
||||
foreach ($this->access->editableLiveTemplates()['templates'] as $template) {
|
||||
if ($template->default) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default template's layout of $area when it can be reused: a published layout of that area the user may
|
||||
* edit (ruling Q8 – Divi would silently drop anything else, F17). Missing, trashed and not editable read the
|
||||
* same (P12). It is referenced by ID, never copied, so no stored content moves (StoredNodeGuard not needed).
|
||||
*/
|
||||
private function reusable(string $area, ?Template $default): ?int
|
||||
{
|
||||
$id = $default?->layouts[$area]['id'] ?? 0;
|
||||
$post = $id > 0 ? get_post($id) : null;
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::LAYOUT_TYPES[$area]
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
|| ! current_user_can('edit_post', $id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{source: string, title: string, nodes: list<mixed>, id: int}> $plan
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function create(string $title, array $plan, array $useOn, array $excludeFrom): array|WP_Error
|
||||
{
|
||||
$store = $this->access->store();
|
||||
$build = new TemplateBuild($store, $this->log, $this->name());
|
||||
$layouts = [];
|
||||
$warnings = [];
|
||||
try {
|
||||
foreach ($plan as $area => $spec) {
|
||||
$layouts[$area] = ['id' => $spec['id'], 'enabled' => $spec['source'] !== 'hidden'];
|
||||
if ($spec['source'] !== 'new') {
|
||||
continue;
|
||||
}
|
||||
$id = $build->layout($area, $spec['title']);
|
||||
$layouts[$area]['id'] = $id;
|
||||
if ($spec['nodes'] === []) {
|
||||
continue;
|
||||
}
|
||||
$composition = $this->composition->forPost($id, $area);
|
||||
$layout = LayoutBuilder::build($composition, $spec['nodes'], $area . '.nodes');
|
||||
if ($layout instanceof WP_Error) {
|
||||
return $build->refuse($layout);
|
||||
}
|
||||
$warnings = array_merge(
|
||||
$warnings,
|
||||
$composition->warnings()->collect(NodeIndex::build($layout['document']), $layout['blocks']),
|
||||
$this->saver->saveNew(
|
||||
$id,
|
||||
Serializer::document($layout['document']),
|
||||
SaveScope::ThemeBuilderLayout
|
||||
)
|
||||
);
|
||||
if ($area === 'body' && $this->edits !== null) {
|
||||
// E12: fields of the new body against the post types of the new conditions – warnings only.
|
||||
$warnings = array_merge($warnings, $this->edits->checkFields(
|
||||
$layout['blocks'],
|
||||
Template::postTypesOf($useOn),
|
||||
'Das neue Body-Layout'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$themeBuilderId = $build->themeBuilder();
|
||||
$templateId = $build->template($themeBuilderId, [
|
||||
'title' => $title,
|
||||
'layouts' => $layouts,
|
||||
'use_on' => $useOn,
|
||||
'exclude_from' => $excludeFrom,
|
||||
]);
|
||||
$mismatch = self::mismatch($store->template($templateId, false), $layouts, $useOn, $excludeFrom);
|
||||
if ($mismatch !== null) {
|
||||
throw new RuntimeException($mismatch);
|
||||
}
|
||||
$build->attach($themeBuilderId, $templateId);
|
||||
} catch (WriteFailed $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$build->rollBack($e);
|
||||
}
|
||||
|
||||
$after = [];
|
||||
FollowUp::run(
|
||||
static fn () => $store->clearCaches(),
|
||||
'theme builder cache',
|
||||
'Der Cache des Theme Builders konnte nicht geleert werden; die Vorlage ist angelegt (deaktiviert).',
|
||||
$after
|
||||
);
|
||||
$newLayouts = [];
|
||||
foreach ($plan as $area => $spec) {
|
||||
if ($spec['source'] === 'new') {
|
||||
$newLayouts[] = $area . ' ' . $layouts[$area]['id'];
|
||||
}
|
||||
}
|
||||
$logId = $this->log->record(
|
||||
$this->name(),
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
(string) $templateId,
|
||||
sprintf(
|
||||
'Theme Builder: Vorlage „%s“ angelegt (deaktiviert; neue Layouts: %s)',
|
||||
$title,
|
||||
$newLayouts === [] ? 'keine' : implode(', ', $newLayouts)
|
||||
)
|
||||
);
|
||||
$template = $store->template($templateId, true);
|
||||
$out = [];
|
||||
foreach ($layouts as $area => $layout) {
|
||||
$out[$area] = $layout + ['source' => $plan[$area]['source']];
|
||||
}
|
||||
|
||||
return ToolResult::ok(sprintf(
|
||||
'Theme-Builder-Vorlage „%s“ angelegt – deaktiviert, ohne Wirkung auf die Website. Aktivieren nur mit '
|
||||
. 'lit/tb-activate.',
|
||||
$title
|
||||
))
|
||||
->with('template_id', $templateId)
|
||||
->with('template_rev', $template?->rev() ?? '')
|
||||
->with('enabled', false)
|
||||
->with('layouts', $out)
|
||||
->warnings(array_merge($warnings, $after, [self::STALE_EDITOR]))
|
||||
->logId($logId)
|
||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the stored template is not what was asked for (Divi silently drops layout IDs, F17), else null.
|
||||
*
|
||||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
*/
|
||||
private static function mismatch(?Template $stored, array $layouts, array $useOn, array $excludeFrom): ?string
|
||||
{
|
||||
if ($stored === null) {
|
||||
return 'template not readable after store';
|
||||
}
|
||||
if ($stored->enabled || $stored->default) {
|
||||
return 'template stored enabled or as default';
|
||||
}
|
||||
foreach ($layouts as $area => $layout) {
|
||||
if ($stored->layouts[$area] !== $layout) {
|
||||
return sprintf('layout %s stored as %d', $area, $stored->layouts[$area]['id']);
|
||||
}
|
||||
}
|
||||
|
||||
$same = $stored->useOn === $useOn && $stored->excludeFrom === $excludeFrom;
|
||||
|
||||
return $same ? null : 'conditions stored differently';
|
||||
}
|
||||
|
||||
/** @return array<string, string> The refusal's fields, or its message under "<area>.nodes". */
|
||||
private static function fields(WP_Error $error, string $area): array
|
||||
{
|
||||
$fields = Errors::fieldsOf($error);
|
||||
|
||||
return $fields !== [] ? $fields : [$area . '.nodes' => Errors::clientMessage($error)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/tb-list (spec §5.7): the templates of the live Theme Builder with conditions (Divi's labels), layouts
|
||||
* (title, shared with other templates – the "global" header/footer is the default template's layout shared by
|
||||
* ID), template_rev for the writing TB tools; templates a later Theme Builder save removed ("detached", with
|
||||
* Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be
|
||||
* reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a
|
||||
* template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it
|
||||
* reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for
|
||||
* layouts the user may edit; condition labels name objects by the stored ID only, never by title.
|
||||
*/
|
||||
final class TbListAbility implements Ability
|
||||
{
|
||||
public const DETACHED_LIMIT = 50;
|
||||
|
||||
/** At most this many detached IDs are read to find DETACHED_LIMIT the user may edit (review M-4). */
|
||||
public const DETACHED_SCAN = 500;
|
||||
|
||||
public function __construct(private ThemeBuilderAccess $access, private TemplateConditions $conditions)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/tb-list';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Theme-Builder-Vorlagen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet die Vorlagen des Divi Theme Builders: aktiv oder deaktiviert, Bedingungen mit Beschriftung, '
|
||||
. 'Layouts für Kopfzeile, Inhalt und Fußzeile (Layout-IDs lassen sich mit den Divi-Werkzeugen bearbeiten, '
|
||||
. 'z. B. lit-divi-outline mit post_id) und template_rev für lit/tb-assign und lit/tb-activate. '
|
||||
. 'template_id: nur diese Vorlage. conditions: true liefert alle Bedingungen, die Divi anbietet; IDs, die '
|
||||
. 'auf „:“ enden, brauchen ein Objekt (z. B. "singular:post_type:page:id:42"). detached: Vorlagen, die ein '
|
||||
. 'späteres Speichern im Theme Builder entfernt hat; nur auf ausdrücklichen Wunsch wieder zuordnen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'conditions' => ['type' => 'boolean', 'description' => 'Divis Bedingungen mitliefern.'],
|
||||
],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
$objects = ['type' => 'array', 'items' => ['type' => 'object']];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'theme_builder_id' => ['type' => 'integer'],
|
||||
'draft_pending' => ['type' => 'boolean'],
|
||||
'hidden_live' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Aktive Vorlagen, die der Nutzer nicht bearbeiten darf (nur die Anzahl).',
|
||||
],
|
||||
'templates' => $objects,
|
||||
'detached' => $objects,
|
||||
'conditions' => $objects,
|
||||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
],
|
||||
'required' => ['theme_builder_id', 'draft_pending', 'hidden_live', 'templates', 'detached', 'notes'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::read();
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return $this->access->canUse() ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = $this->access->canUse();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$store = $this->access->store();
|
||||
$split = $this->access->editableLiveTemplates();
|
||||
$live = $split['templates'];
|
||||
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
|
||||
// Review M-4: the limit counts only templates the user may edit; foreign ones do not crowd them out.
|
||||
$detached = [];
|
||||
$detachedIds = $store->detachedIds(self::DETACHED_SCAN);
|
||||
$moreDetached = count($detachedIds) >= self::DETACHED_SCAN;
|
||||
foreach ($detachedIds as $id) {
|
||||
$template = $store->template($id, false);
|
||||
if ($template === null || ! $this->access->canEditTemplate($template->id)) {
|
||||
continue;
|
||||
}
|
||||
if (count($detached) >= self::DETACHED_LIMIT) {
|
||||
$moreDetached = true;
|
||||
break;
|
||||
}
|
||||
$detached[] = $template;
|
||||
}
|
||||
// Only what the user may see counts for shared_with (B11).
|
||||
$shared = array_merge($live, $detached);
|
||||
|
||||
if (isset($input['template_id'])) {
|
||||
$id = PostGuard::id($input['template_id']);
|
||||
if ($id === null) {
|
||||
return ThemeBuilderAccess::invalidTemplateId();
|
||||
}
|
||||
$only = static fn (array $list): array => array_values(
|
||||
array_filter($list, static fn (Template $template): bool => $template->id === $id)
|
||||
);
|
||||
$live = $only($live);
|
||||
$detached = $only($detached);
|
||||
if ($live === [] && $detached === []) {
|
||||
return Errors::notFound(
|
||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||
'Vorlagen mit lit/tb-list ohne template_id auflisten.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$draft = $store->draftId() > 0;
|
||||
$notes = [];
|
||||
if ($draft) {
|
||||
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
|
||||
. 'verworfen sind, ändert LIT MCP keine Vorlagen – nur ausblenden (lit/tb-activate mit enabled: '
|
||||
. 'false) geht.';
|
||||
}
|
||||
if ($hiddenLive > 0) {
|
||||
$notes[] = sprintf(
|
||||
'Weitere aktive Vorlagen ohne Berechtigung: %d (nicht aufgeführt; sie können sich mit den '
|
||||
. 'aufgeführten überschneiden).',
|
||||
$hiddenLive
|
||||
);
|
||||
}
|
||||
$trashed = array_values(array_filter(
|
||||
$live,
|
||||
static fn (Template $t): bool => $t->isLive() && $t->status !== 'publish'
|
||||
));
|
||||
if ($trashed !== []) {
|
||||
// Review M-10: tb-assign and switching on read such a template as missing (ThemeBuilderAccess::template()).
|
||||
$notes[] = sprintf(
|
||||
'Aktiv, aber nicht veröffentlicht (z. B. im Papierkorb, siehe status): %s – nur deaktivieren '
|
||||
. '(lit/tb-activate enabled: false); Bedingungen ändern oder erneut aktivieren geht erst, wenn '
|
||||
. 'die Vorlage in WordPress wiederhergestellt ist.',
|
||||
implode(', ', array_map(static fn (Template $t): string => (string) $t->id, $trashed))
|
||||
);
|
||||
}
|
||||
if ($detached !== []) {
|
||||
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
|
||||
. 'hat sie dort bewusst gelöscht; Divi löscht als ungenutzt markierte Vorlagen und Layouts 7 Tage '
|
||||
. 'nach der Markierung. Nur auf ausdrücklichen Wunsch wieder zuordnen (bleibt deaktiviert): '
|
||||
. 'lit/tb-assign mit reattach: true.';
|
||||
}
|
||||
if ($moreDetached && ! isset($input['template_id'])) {
|
||||
$notes[] = sprintf(
|
||||
'Weitere entfernte Vorlagen nicht aufgeführt (höchstens %d je Aufruf).',
|
||||
self::DETACHED_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'theme_builder_id' => $store->liveId(),
|
||||
'draft_pending' => $draft,
|
||||
'hidden_live' => $hiddenLive,
|
||||
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
|
||||
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
|
||||
'notes' => $notes,
|
||||
];
|
||||
if (($input['conditions'] ?? false) === true) {
|
||||
$result['conditions'] = $this->conditions->available();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Template> $visible Templates the user may see (the shared_with candidates).
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function describe(Template $template, array $visible): array
|
||||
{
|
||||
$row = $template->toArray();
|
||||
foreach (Template::AREAS as $area) {
|
||||
$id = $template->layouts[$area]['id'];
|
||||
$others = array_filter(
|
||||
$visible,
|
||||
static fn (Template $other): bool => $id > 0 && $other->id !== $template->id
|
||||
&& $other->layouts[$area]['id'] === $id
|
||||
);
|
||||
$row['layouts'][$area]['title'] = $id > 0 && current_user_can('edit_post', $id)
|
||||
? (string) get_the_title($id)
|
||||
: '';
|
||||
$row['layouts'][$area]['shared_with'] = array_values(array_map(
|
||||
static fn (Template $other): int => $other->id,
|
||||
$others
|
||||
));
|
||||
}
|
||||
$row['use_on_labels'] = array_map($this->conditions->label(...), $template->useOn);
|
||||
$row['exclude_from_labels'] = array_map($this->conditions->label(...), $template->excludeFrom);
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521;
|
||||
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
|
||||
* "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10).
|
||||
* rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
|
||||
*/
|
||||
final class Template
|
||||
{
|
||||
public const AREAS = ['header', 'body', 'footer'];
|
||||
|
||||
/**
|
||||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||||
* @param list<string> $useOn
|
||||
* @param list<string> $excludeFrom
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $title,
|
||||
public readonly string $status,
|
||||
public readonly bool $default,
|
||||
public readonly bool $enabled,
|
||||
public readonly array $layouts,
|
||||
public readonly array $useOn,
|
||||
public readonly array $excludeFrom,
|
||||
public readonly bool $attached,
|
||||
public readonly ?string $markedUnused
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $divi et_theme_builder_get_template() */
|
||||
public static function fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self
|
||||
{
|
||||
$layouts = [];
|
||||
foreach (self::AREAS as $area) {
|
||||
$layout = is_array($divi['layouts'][$area] ?? null) ? $divi['layouts'][$area] : [];
|
||||
$layouts[$area] = ['id' => (int) ($layout['id'] ?? 0), 'enabled' => (bool) ($layout['enabled'] ?? true)];
|
||||
}
|
||||
|
||||
return new self(
|
||||
(int) ($divi['id'] ?? 0),
|
||||
(string) ($divi['title'] ?? ''),
|
||||
$status,
|
||||
(bool) ($divi['default'] ?? false),
|
||||
(bool) ($divi['enabled'] ?? false),
|
||||
$layouts,
|
||||
self::strings($divi['use_on'] ?? []),
|
||||
self::strings($divi['exclude_from'] ?? []),
|
||||
$attached,
|
||||
$markedUnused
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether visitors can see it: listed by the live Theme Builder post and enabled. Divi renders such a
|
||||
* template whatever its post status (et_theme_builder_get_template() and ThemeBuilderRequest only read
|
||||
* the enabled flag), so the status does not count here – fail closed (ruling Q8/B8).
|
||||
*/
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->attached && $this->enabled;
|
||||
}
|
||||
|
||||
/** Whether visitors see layout $layoutId as its $area through this template. */
|
||||
public function showsLayout(string $area, int $layoutId): bool
|
||||
{
|
||||
return $this->isLive()
|
||||
&& ($this->layouts[$area]['id'] ?? 0) === $layoutId
|
||||
&& ($this->layouts[$area]['enabled'] ?? false);
|
||||
}
|
||||
|
||||
/** template_rev: changes with everything the Theme Builder tools read or write. */
|
||||
public function rev(): string
|
||||
{
|
||||
$state = [$this->id, $this->title, $this->enabled, $this->layouts];
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure value object, no WordPress.
|
||||
return Rev::of((string) json_encode(
|
||||
array_merge($state, [$this->useOn, $this->excludeFrom, $this->attached]),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||||
public function postTypes(): array
|
||||
{
|
||||
return self::postTypesOf($this->useOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $useOn
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function postTypesOf(array $useOn): array
|
||||
{
|
||||
$types = [];
|
||||
foreach ($useOn as $condition) {
|
||||
$parts = explode(':', $condition);
|
||||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||||
$types[] = $parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($types));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'status' => $this->status,
|
||||
'default' => $this->default,
|
||||
'enabled' => $this->enabled,
|
||||
'live' => $this->isLive(),
|
||||
'attached' => $this->attached,
|
||||
'marked_unused' => $this->markedUnused,
|
||||
'template_rev' => $this->rev(),
|
||||
'layouts' => $this->layouts,
|
||||
'use_on' => $this->useOn,
|
||||
'exclude_from' => $this->excludeFrom,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live templates other than $exceptId that share a condition of $useOn (Divi then picks one of them).
|
||||
*
|
||||
* @param list<Template> $live
|
||||
* @param list<string> $useOn
|
||||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||||
*/
|
||||
public static function overlaps(array $live, int $exceptId, array $useOn): array
|
||||
{
|
||||
$overlaps = [];
|
||||
foreach ($live as $other) {
|
||||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||||
continue;
|
||||
}
|
||||
$shared = array_values(array_intersect($other->useOn, $useOn));
|
||||
if ($shared !== []) {
|
||||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $shared];
|
||||
}
|
||||
}
|
||||
|
||||
return $overlaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live templates other than $exceptId whose conditions are not identical to one of $useOn but may cover
|
||||
* the same pages (ruling Q8/B9 – "kann sich überschneiden"): same family, e.g. all pages vs. one page,
|
||||
* all archives vs. one category archive, posts in a category vs. any single post, the front page vs. pages.
|
||||
* Deliberately generous – the dry run is the user's only preview. Identical conditions are overlaps().
|
||||
*
|
||||
* @param list<Template> $live
|
||||
* @param list<string> $useOn
|
||||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||||
*/
|
||||
public static function possibleOverlaps(array $live, int $exceptId, array $useOn): array
|
||||
{
|
||||
$overlaps = [];
|
||||
foreach ($live as $other) {
|
||||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||||
continue;
|
||||
}
|
||||
$conditions = [];
|
||||
foreach ($other->useOn as $theirs) {
|
||||
if (in_array($theirs, $useOn, true)) {
|
||||
continue; // identical: overlaps()
|
||||
}
|
||||
foreach ($useOn as $ours) {
|
||||
if (self::mayOverlap($theirs, $ours)) {
|
||||
$conditions[] = $theirs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($conditions !== []) {
|
||||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $conditions];
|
||||
}
|
||||
}
|
||||
|
||||
return $overlaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlap details of a dry run (tb-assign, tb-activate) for template $exceptId with conditions $useOn:
|
||||
* identical and possible overlaps with the live templates the user may edit, and only the number of
|
||||
* overlapping live templates the user may not edit (ruling Q21, Q8/B11).
|
||||
*
|
||||
* @param array{templates: list<Template>, hidden: list<Template>} $live ThemeBuilderAccess::editableLiveTemplates()
|
||||
* @param list<string> $useOn
|
||||
* @return array{overlaps: list<array{id: int, title: string, conditions: list<string>}>,
|
||||
* possible_overlaps: list<array{id: int, title: string, conditions: list<string>}>, hidden_overlaps: int}
|
||||
*/
|
||||
public static function overlapDetails(array $live, int $exceptId, array $useOn): array
|
||||
{
|
||||
$hidden = array_merge(
|
||||
self::overlaps($live['hidden'], $exceptId, $useOn),
|
||||
self::possibleOverlaps($live['hidden'], $exceptId, $useOn)
|
||||
);
|
||||
|
||||
return [
|
||||
'overlaps' => self::overlaps($live['templates'], $exceptId, $useOn),
|
||||
'possible_overlaps' => self::possibleOverlaps($live['templates'], $exceptId, $useOn),
|
||||
'hidden_overlaps' => count(array_unique(array_column($hidden, 'id'))),
|
||||
];
|
||||
}
|
||||
|
||||
/** The sentence for hidden_overlaps > 0 (never names them). */
|
||||
public static function hiddenOverlapsNote(int $count): string
|
||||
{
|
||||
return $count > 0
|
||||
? sprintf(' Weitere aktive Vorlagen ohne Berechtigung, die sich überschneiden können: %d.', $count)
|
||||
: '';
|
||||
}
|
||||
|
||||
private static function mayOverlap(string $a, string $b): bool
|
||||
{
|
||||
foreach (self::scopesOf($a) as $x) {
|
||||
foreach (self::scopesOf($b) as $y) {
|
||||
if ($x === $y || str_starts_with($x, $y . ':') || str_starts_with($y, $x . ':')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
|
||||
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
|
||||
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function scopesOf(string $condition): array
|
||||
{
|
||||
if ($condition === 'homepage') {
|
||||
return ['homepage', 'singular:post_type:page', 'archive:post_type:post'];
|
||||
}
|
||||
|
||||
$parts = explode(':', $condition);
|
||||
$second = $parts[1] ?? '';
|
||||
if (($parts[0] === 'singular' && $second === 'taxonomy') || $second === 'all') {
|
||||
return [$parts[0]];
|
||||
}
|
||||
|
||||
return [implode(':', array_slice($parts, 0, in_array($second, ['post_type', 'taxonomy'], true) ? 3 : 2))];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function strings(mixed $list): array
|
||||
{
|
||||
return array_values(array_map('strval', array_filter(is_array($list) ? $list : [], 'is_scalar')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Rollback;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The transaction of lit/tb-create-template (all or nothing, like NewDraft for one post): records every post
|
||||
* created for it (layouts, a live Theme Builder post Divi creates on the way – ruling Q8/B12 –, the template)
|
||||
* and the assignment to the Theme Builder post; refuse() and rollBack() remove all of it again, the assignment
|
||||
* first, then the posts newest first (wp_delete_post with force – nothing can use them yet; Divi's own trash
|
||||
* routine is never called). A cleanup that fails is an honest partial_write naming the IDs (logged).
|
||||
*/
|
||||
final class TemplateBuild
|
||||
{
|
||||
public const REASON = 'Die Theme-Builder-Vorlage konnte nicht angelegt werden.';
|
||||
|
||||
/** @var list<int> */
|
||||
private array $created = [];
|
||||
|
||||
/** @var list<int> Posts the last undo() could not remove (or the template still listed). */
|
||||
private array $left = [];
|
||||
|
||||
/** @var array{0: int, 1: int}|null Theme Builder post, template. */
|
||||
private ?array $attached = null;
|
||||
|
||||
public function __construct(private DiviThemeBuilder $store, private ChangeLog $log, private string $ability)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function layout(string $area, string $title): int
|
||||
{
|
||||
$id = $this->recording(fn (): int => $this->store->insertLayout($area, $title));
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live Theme Builder post; when the site has none yet, Divi creates it like on its first save and the
|
||||
* new post belongs to this build (B12).
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function themeBuilder(): int
|
||||
{
|
||||
$id = $this->store->liveId(false);
|
||||
if ($id > 0) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
$id = $this->store->liveId(true);
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('no live Theme Builder post');
|
||||
}
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||||
* exclude_from: list<string>} $template
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function template(int $themeBuilderId, array $template): int
|
||||
{
|
||||
$id = $this->recording(fn (): int => $this->store->storeTemplate($themeBuilderId, $template));
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
public function attach(int $themeBuilderId, int $templateId): void
|
||||
{
|
||||
$this->attached = [$themeBuilderId, $templateId];
|
||||
$this->store->attach($themeBuilderId, $templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one Divi insert; posts Divi created before it failed (InsertedBeforeFailure, ruling Q19) join the build.
|
||||
*
|
||||
* @param callable(): int $insert
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function recording(callable $insert): int
|
||||
{
|
||||
try {
|
||||
return $insert();
|
||||
} catch (InsertedBeforeFailure $e) {
|
||||
foreach ($e->postIds as $id) {
|
||||
if (! in_array($id, $this->created, true)) {
|
||||
$this->created[] = $id;
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function created(): array
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal after posts exist (a layout the new post's own dynamic content map refuses): everything created
|
||||
* is removed, then the refusal is returned.
|
||||
*
|
||||
* @throws WriteFailed When the cleanup fails.
|
||||
*/
|
||||
public function refuse(WP_Error $refusal): WP_Error
|
||||
{
|
||||
$failures = $this->undo();
|
||||
if ($failures !== []) {
|
||||
throw $this->partial(
|
||||
new RuntimeException(implode('; ', $failures)),
|
||||
' after refusal: ' . implode('; ', $failures)
|
||||
);
|
||||
}
|
||||
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
/** @throws WriteFailed Always. */
|
||||
public function rollBack(Throwable $cause): never
|
||||
{
|
||||
$failures = $this->undo();
|
||||
if ($failures !== []) {
|
||||
throw $this->partial($cause, '; rollback: ' . implode('; ', $failures));
|
||||
}
|
||||
|
||||
throw WriteFailed::rolledBack(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'Theme Builder template not created: ' . $cause->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches, then deletes every created post newest first with its layout_type term relationships (Divi sets
|
||||
* them in insert_layout; WordPress does not remove them because that taxonomy is not registered for the TB
|
||||
* layout types). Notes in $left what could not be removed.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function undo(): array
|
||||
{
|
||||
$this->left = [];
|
||||
$steps = [];
|
||||
if ($this->attached !== null) {
|
||||
[$themeBuilderId, $templateId] = $this->attached;
|
||||
$steps[] = function () use ($themeBuilderId, $templateId): void {
|
||||
try {
|
||||
$this->store->detach($themeBuilderId, $templateId);
|
||||
} catch (Throwable $e) {
|
||||
$this->left[] = $templateId;
|
||||
throw $e;
|
||||
}
|
||||
};
|
||||
}
|
||||
foreach (array_reverse($this->created) as $id) {
|
||||
$steps[] = function () use ($id): void {
|
||||
try {
|
||||
wp_delete_object_term_relationships($id, 'layout_type');
|
||||
$deleted = wp_delete_post($id, true);
|
||||
} catch (Throwable $e) {
|
||||
$deleted = false;
|
||||
}
|
||||
if (! $deleted) {
|
||||
if (! in_array($id, $this->left, true)) {
|
||||
$this->left[] = $id;
|
||||
}
|
||||
throw new RuntimeException(sprintf('post %d not deleted', $id));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return Rollback::attempt(...$steps);
|
||||
}
|
||||
|
||||
private function partial(Throwable $cause, string $detail): WriteFailed
|
||||
{
|
||||
$ids = implode(', ', $this->left);
|
||||
$this->log->recordPartial(
|
||||
$this->ability,
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
$ids,
|
||||
sprintf('Theme Builder: Anlegen nicht vollständig zurückgenommen (Beiträge %s)', $ids)
|
||||
);
|
||||
|
||||
return WriteFailed::partial(
|
||||
self::REASON,
|
||||
$cause,
|
||||
'Theme Builder rollback incomplete' . $detail,
|
||||
state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids),
|
||||
hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids),
|
||||
details: ['left_ids' => $this->left]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list
|
||||
* (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry
|
||||
* plus one object segment, split the way Divi reads it back (et_theme_builder_load_template_setting_options():
|
||||
* the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post
|
||||
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
|
||||
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
|
||||
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
|
||||
* Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with
|
||||
* other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's
|
||||
* Theme Builder (review M-6).
|
||||
*/
|
||||
final class TemplateConditions
|
||||
{
|
||||
public const MAX = 50;
|
||||
|
||||
private const SEPARATOR = ':';
|
||||
|
||||
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
||||
|
||||
/** @var array<string, array{label: string, type: ?string, value: ?string}>|null */
|
||||
private ?array $flat = null;
|
||||
|
||||
public function __construct(private DiviThemeBuilder $store)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a client's condition list for field $field: null = no conditions; errors are keyed "<field>"
|
||||
* (the list itself) or "<field>.<index>". Duplicates are dropped.
|
||||
*
|
||||
* @return array{ids: list<string>, errors: array<string, string>}
|
||||
*/
|
||||
public function check(mixed $conditions, string $field): array
|
||||
{
|
||||
if ($conditions === null) {
|
||||
return ['ids' => [], 'errors' => []];
|
||||
}
|
||||
|
||||
if (! is_array($conditions) || ! array_is_list($conditions)) {
|
||||
return ['ids' => [], 'errors' => [$field => 'Liste von Bedingungs-IDs erwartet']];
|
||||
}
|
||||
|
||||
if (count($conditions) > self::MAX) {
|
||||
return ['ids' => [], 'errors' => [$field => sprintf('höchstens %d Bedingungen', self::MAX)]];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
$errors = [];
|
||||
foreach ($conditions as $index => $condition) {
|
||||
$problem = is_string($condition) ? $this->problem($condition) : 'Bedingungs-ID als Text erwartet';
|
||||
if ($problem === null) {
|
||||
$ids[] = (string) $condition;
|
||||
} else {
|
||||
$errors[$field . '.' . $index] = $problem;
|
||||
}
|
||||
}
|
||||
|
||||
return ['ids' => array_values(array_unique($ids)), 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Divi's label, for an ID with object "<label>: <object>" (the object as stored, never its title). */
|
||||
public function label(string $id): string
|
||||
{
|
||||
$flat = $this->flat();
|
||||
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
|
||||
return $flat[$id]['label'];
|
||||
}
|
||||
|
||||
$split = $this->split($id);
|
||||
|
||||
return $split === null ? $id : sprintf('%s: %s', $flat[$split[0]]['label'], $split[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's conditions for lit/tb-list: "needs" names what an ID ending in ":" takes, e.g. "post_type:page".
|
||||
*
|
||||
* @return list<array{id: string, label: string, needs: ?string}>
|
||||
*/
|
||||
public function available(): array
|
||||
{
|
||||
$list = [];
|
||||
foreach ($this->flat() as $id => $setting) {
|
||||
$id = (string) $id;
|
||||
$list[] = [
|
||||
'id' => $id,
|
||||
'label' => $setting['label'],
|
||||
'needs' => str_ends_with($id, self::SEPARATOR)
|
||||
? trim(($setting['type'] ?? '') . self::SEPARATOR . ($setting['value'] ?? ''), self::SEPARATOR)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function problem(string $id): ?string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_:\-]{1,200}$/D', $id) !== 1) {
|
||||
return 'ungültige Bedingungs-ID (erlaubt: Buchstaben, Ziffern, „_“, „-“ und „:“)';
|
||||
}
|
||||
|
||||
$flat = $this->flat();
|
||||
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($flat[$id])) {
|
||||
return sprintf('Bedingung %s braucht ein Objekt am Ende (siehe „needs“ in lit/tb-list)', $id);
|
||||
}
|
||||
|
||||
$split = $this->split($id);
|
||||
if ($split === null) {
|
||||
return 'unbekannte Bedingung (verfügbare: lit/tb-list mit conditions: true)';
|
||||
}
|
||||
|
||||
return $this->objectProblem($flat[$split[0]], $split[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Divi's parent ID ending in ":", object] as Divi reads the stored ID back (last segment = object),
|
||||
* or null when that parent is not in Divi's list or the object is empty.
|
||||
*
|
||||
* @return array{string, string}|null
|
||||
*/
|
||||
private function split(string $id): ?array
|
||||
{
|
||||
$cut = strrpos($id, self::SEPARATOR);
|
||||
if ($cut === false || $cut === strlen($id) - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parent = substr($id, 0, $cut + 1);
|
||||
|
||||
return isset($this->flat()[$parent]) ? [$parent, substr($id, $cut + 1)] : null;
|
||||
}
|
||||
|
||||
/** @param array{label: string, type: ?string, value: ?string} $setting */
|
||||
private function objectProblem(array $setting, string $object): ?string
|
||||
{
|
||||
$value = (string) $setting['value'];
|
||||
$id = PostIdParser::parse($object);
|
||||
|
||||
if ($setting['type'] === 'post_type') {
|
||||
return $this->postProblem($value, $id, $object);
|
||||
}
|
||||
if ($setting['type'] === 'taxonomy') {
|
||||
$valid = $id !== null && get_term($id, $value) instanceof WP_Term && current_user_can('assign_term', $id);
|
||||
|
||||
return $valid
|
||||
? null
|
||||
: sprintf('Begriff %s gibt es in „%s“ nicht oder er ist nicht zuweisbar', $object, $value);
|
||||
}
|
||||
if ($setting['type'] === 'user') {
|
||||
$valid = $id !== null && current_user_can('list_users') && get_userdata($id) !== false;
|
||||
|
||||
return $valid ? null : 'Autoren-Bedingungen nur mit dem Recht list_users und einer vorhandenen Benutzer-ID';
|
||||
}
|
||||
if ($setting['type'] === 'user_role') {
|
||||
return wp_roles()->is_role($object) ? null : sprintf('Rolle „%s“ gibt es nicht', $object);
|
||||
}
|
||||
|
||||
return 'diese Bedingung nur im Divi Theme Builder setzen';
|
||||
}
|
||||
|
||||
private function postProblem(string $postType, ?int $id, string $object): ?string
|
||||
{
|
||||
$post = $id === null ? null : get_post($id);
|
||||
$valid = $post instanceof WP_Post
|
||||
&& (string) $post->post_type === $postType
|
||||
&& ! in_array((string) $post->post_status, self::DEAD, true)
|
||||
&& current_user_can('read_post', $id);
|
||||
|
||||
return $valid
|
||||
? null
|
||||
: sprintf('Eintrag %s vom Typ „%s“ gibt es nicht oder er ist nicht lesbar', $object, $postType);
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, type: ?string, value: ?string}> */
|
||||
private function flat(): array
|
||||
{
|
||||
return $this->flat ??= $this->store->flatConditions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs
|
||||
* edit_others_posts (Divi's own TB requirement, F19/F23), edit_theme_options (the gate of Divi's Theme
|
||||
* Builder page, theme-builder.php:2059-2063, ruling Q15) and the Divi role option theme_builder (off for
|
||||
* every role but administrator by default, F12); one layout or template additionally edit_post on it.
|
||||
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
|
||||
* (P12 pattern); the Theme Builder tools themselves (tb-list, tb-create-template, tb-assign, tb-activate) refuse
|
||||
* without canUse() as forbidden, whatever the ID (review M-9). With the Theme Builder rights, a template the user
|
||||
* may not edit still reads like a missing one (template(), ruling Q18 – templates are listed by tb-list), while a
|
||||
* layout without edit_post answers forbidden (layout(): only accounts with the Theme Builder rights get there, and
|
||||
* tb-list names the layout IDs of their templates). The group's "nur lesen" stops writes (E9). Not final so
|
||||
* abilities can be tested with a mock.
|
||||
*/
|
||||
class ThemeBuilderAccess
|
||||
{
|
||||
/** Tool group "Divi Theme Builder" (Tools tab). */
|
||||
public const GROUP = 'divi-theme-builder';
|
||||
|
||||
/**
|
||||
* @param (Closure(): bool)|null $writable Group writable (neither "nur lesen" nor disabled); null = always.
|
||||
* @param (Closure(): bool)|null $visible Group not disabled; null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private DiviThemeBuilder $store,
|
||||
private DiviRoles $roles,
|
||||
private ?Closure $writable = null,
|
||||
private ?Closure $visible = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(): DiviThemeBuilder
|
||||
{
|
||||
return $this->store;
|
||||
}
|
||||
|
||||
public function roles(): DiviRoles
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function canUse(): ?WP_Error
|
||||
{
|
||||
if (! current_user_can('edit_others_posts')) {
|
||||
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).');
|
||||
}
|
||||
if (! current_user_can('edit_theme_options')) {
|
||||
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_theme_options).');
|
||||
}
|
||||
|
||||
return $this->roles->refusal('theme_builder');
|
||||
}
|
||||
|
||||
/** Whether Theme Builder objects exist for this connection: group not disabled and canUse(). */
|
||||
public function visible(): bool
|
||||
{
|
||||
return ($this->visible === null || ($this->visible)()) && $this->canUse() === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Theme Builder access of a global library edit (GlobalUsages, ruling Q17 M1, review I-2): visible = Theme
|
||||
* Builder posts may be named (visible()); usable = a global edit may change them – the rights of every Theme
|
||||
* Builder write (canUse(), part of visible()) and a writable group, as the Divi tools on layouts.
|
||||
*
|
||||
* @return array{visible: bool, usable: bool}
|
||||
*/
|
||||
public function usageState(): array
|
||||
{
|
||||
$visible = $this->visible();
|
||||
|
||||
return ['visible' => $visible, 'usable' => $visible && $this->writableGate() === null];
|
||||
}
|
||||
|
||||
public function writableGate(): ?WP_Error
|
||||
{
|
||||
if ($this->writable === null || ($this->writable)()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::forbidden(
|
||||
'Schreiben im Divi Theme Builder ist für diese Verbindung abgeschaltet (Gruppe „Divi Theme Builder“ auf '
|
||||
. '„nur lesen“ oder deaktiviert).',
|
||||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout $postId for the Divi tools (E1): null when it is no Theme Builder layout (the caller goes on);
|
||||
* like a missing ID without visible(); forbidden without edit_post; for writing also the group gate and
|
||||
* a published layout (Divi's TB layouts are always published).
|
||||
*/
|
||||
public function layout(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 || DiviThemeBuilder::areaOf((string) $post->post_type) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $post->ID;
|
||||
if (! $this->visible()) {
|
||||
return PostGuard::notFound($id);
|
||||
}
|
||||
|
||||
if (! current_user_can('edit_post', $id)) {
|
||||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Layout %d zu bearbeiten.', $id));
|
||||
}
|
||||
|
||||
if (! $forWrite) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
$gate = $this->writableGate();
|
||||
if ($gate !== null) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
if ((string) $post->post_status !== 'publish') {
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Theme-Builder-Layout %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
|
||||
$id,
|
||||
(string) $post->post_status
|
||||
),
|
||||
['post_id' => 'nur aktive Theme-Builder-Layouts']
|
||||
);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template $templateId for the Theme Builder tools. Any other ID, a hidden Theme Builder and a template the
|
||||
* user may not edit (canEditTemplate) answer identically: not found (ruling Q18, P12 – like tb-list, Q8/B11).
|
||||
*/
|
||||
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
|
||||
{
|
||||
$id = PostGuard::id($templateId);
|
||||
if ($id === null) {
|
||||
return self::invalidTemplateId();
|
||||
}
|
||||
|
||||
$post = get_post($id);
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
|| ! $this->canEditTemplate($id)
|
||||
) {
|
||||
return Errors::notFound(
|
||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||
'Vorlagen mit lit/tb-list auflisten.'
|
||||
);
|
||||
}
|
||||
|
||||
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
||||
}
|
||||
|
||||
/** The answer for a template_id that is missing or no ID (template(), lit/tb-list – review M-5). */
|
||||
public static function invalidTemplateId(): WP_Error
|
||||
{
|
||||
return Errors::invalidInput('template_id fehlt oder ist keine ID.', ['template_id' => 'ID aus lit/tb-list']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
|
||||
* (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7),
|
||||
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
|
||||
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
|
||||
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
|
||||
* still reads as missing. The off-switch also works while the Theme Builder has unsaved changes (ruling Q20).
|
||||
*
|
||||
* @param array<string, mixed> $input template_id, template_rev
|
||||
*/
|
||||
public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error
|
||||
{
|
||||
// Review M-9: without the Theme Builder rights refuse like tb-list/tb-create-template – independent of the ID.
|
||||
$refusal = $this->canUse();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$post = $this->template($input['template_id'] ?? null, true);
|
||||
if ($post instanceof WP_Error) {
|
||||
$id = $offSwitch && Errors::codeOf($post) === 'not_found'
|
||||
? $this->liveUnpublished($input['template_id'] ?? null)
|
||||
: null;
|
||||
if ($id === null) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
$gate = $this->writableGate();
|
||||
|
||||
return $gate ?? $this->checkedTemplate($id, $input, true);
|
||||
}
|
||||
|
||||
return $this->checkedTemplate((int) $post->ID, $input, $offSwitch);
|
||||
}
|
||||
|
||||
/**
|
||||
* The templates of the live Theme Builder post split by canEditTemplate() (ruling Q21 – one place for
|
||||
* tb-list and the dry runs of tb-assign/tb-activate): only "templates" may be shown; "hidden" serves counts
|
||||
* ("weitere aktive Vorlagen ohne Berechtigung"), never its IDs, titles or conditions (Q8/B11, P12).
|
||||
*
|
||||
* @return array{templates: list<Template>, hidden: list<Template>}
|
||||
*/
|
||||
public function editableLiveTemplates(): array
|
||||
{
|
||||
$split = ['templates' => [], 'hidden' => []];
|
||||
foreach ($this->store->liveTemplates() as $template) {
|
||||
$split[$this->canEditTemplate($template->id) ? 'templates' : 'hidden'][] = $template;
|
||||
}
|
||||
|
||||
return $split;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11).
|
||||
* $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks
|
||||
* only the rights, not that $id is a template.
|
||||
*/
|
||||
public function canEditTemplate(int $id): bool
|
||||
{
|
||||
return $this->visible() && current_user_can('edit_post', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refusal when a layout $template refers to is no longer a published layout of its area (Divi trashes
|
||||
* layouts marked unused after seven days, stage3b README finding 10); reattach and activation need this
|
||||
* first (ruling Q8/B10). Areas without a layout (ID 0) need nothing.
|
||||
*/
|
||||
public function layoutsRefusal(Template $template): ?WP_Error
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($template->layouts as $area => $layout) {
|
||||
if ($layout['id'] <= 0) {
|
||||
continue;
|
||||
}
|
||||
$post = get_post($layout['id']);
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== (DiviThemeBuilder::LAYOUT_TYPES[$area] ?? null)
|
||||
|| (string) $post->post_status !== 'publish'
|
||||
) {
|
||||
$fields['layouts.' . $area] = sprintf(
|
||||
'Layout %d ist kein aktives Theme-Builder-Layout (%s) mehr',
|
||||
$layout['id'],
|
||||
$area
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fields === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::invalidInput(
|
||||
sprintf(
|
||||
'Theme-Builder-Vorlage %d verweist auf Layouts, die nicht mehr aktiv sind; nichts wurde gespeichert.',
|
||||
$template->id
|
||||
),
|
||||
$fields,
|
||||
'Im Theme Builder (Divi → Theme Builder) die Layouts der Vorlage wiederherstellen oder ersetzen, '
|
||||
. 'dann erneut aufrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating a template with layouts: canUse(), the group gate and publish_posts of the template and
|
||||
* layout types – Divi publishes them at once (F17, F18).
|
||||
*/
|
||||
public function canCreate(): ?WP_Error
|
||||
{
|
||||
$refusal = $this->canUse() ?? $this->writableGate();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
foreach (array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)) as $type) {
|
||||
$object = get_post_type_object($type);
|
||||
$cap = is_object($object) && isset($object->cap->publish_posts)
|
||||
? (string) $object->cap->publish_posts
|
||||
: 'publish_posts';
|
||||
if (! current_user_can($cap)) {
|
||||
return Errors::forbidden(
|
||||
sprintf('Keine Berechtigung, Inhalte des Theme Builders zu veröffentlichen (%s).', $cap)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** E7: while the Theme Builder has unsaved changes (draft), its later save would replace the template list. */
|
||||
public function draftRefusal(): ?WP_Error
|
||||
{
|
||||
if ($this->store->draftId() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::unsupported(
|
||||
'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); solange ändert LIT MCP keine Vorlagen – '
|
||||
. 'ein späteres Speichern dort würde die Zuordnung ersetzen. Nichts wurde gespeichert.',
|
||||
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ID of a template that is not published but live (listed by the live Theme Builder post and enabled) and the
|
||||
* user may edit; null otherwise – the raw lookup of the off-switch (ruling Q16).
|
||||
*/
|
||||
private function liveUnpublished(mixed $templateId): ?int
|
||||
{
|
||||
$id = PostGuard::id($templateId);
|
||||
$post = $id === null ? null : get_post($id);
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||||
|| (string) $post->post_status === 'publish'
|
||||
|| ! $this->canEditTemplate((int) $post->ID)
|
||||
|| ! in_array((int) $post->ID, $this->store->templateIds($this->store->liveId()), true)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->store->template((int) $post->ID, true)?->isLive() === true ? (int) $post->ID : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
|
||||
* The off-switch skips the draft refusal (ruling Q20): hiding a template must always be possible; the caller
|
||||
* warns that saving an open Theme Builder editor may restore its own state.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
private function checkedTemplate(int $id, array $input, bool $offSwitch): Template|WP_Error
|
||||
{
|
||||
$refusal = $offSwitch ? null : $this->draftRefusal();
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$attached = in_array($id, $this->store->templateIds($this->store->liveId()), true);
|
||||
$template = $this->store->template($id, $attached);
|
||||
if ($template === null) {
|
||||
return Errors::notFound(
|
||||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||||
'Vorlagen mit lit/tb-list auflisten.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($template->default) {
|
||||
return Errors::unsupported(
|
||||
'Die Standardvorlage ändert LIT MCP nicht; nichts wurde gespeichert.',
|
||||
'Die Standardvorlage im Divi Theme Builder bearbeiten.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($input['template_rev'] ?? null) || $input['template_rev'] !== $template->rev()) {
|
||||
return Errors::revMismatch(
|
||||
'Die Vorlage wurde seit dem Lesen geändert (template_rev passt nicht); nichts wurde gespeichert.',
|
||||
'Mit lit/tb-list neu lesen und mit der neuen template_rev wiederholen.'
|
||||
);
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The extra rule of a Divi write to a Theme Builder layout (Etappe 3b, constraint "sichtbare Website nur mit
|
||||
* ausdrücklicher Freigabe"): Divi reads TB layouts live (F22), so a layout that a live template (attached to the
|
||||
* live Theme Builder and enabled, whatever its post status – ruling Q8) shows changes the website at once –
|
||||
* without confirm: true nothing is saved and the answer lists those templates (dry run of the fully validated
|
||||
* change, like global modules, answer F2; ruling Q9) – by name only those the user may edit, the others as the
|
||||
* count hidden_templates; confirm is decided on all of them (ruling Q22). A confirmed edit gets the restore point
|
||||
* of every visible change (VisibleEdits, ruling Q2): a backup when revisions are off, refused when it could not be
|
||||
* faithful (P9).
|
||||
* Layouts of disabled or detached templates save at once. The save scope sets the Divi flags and clears every
|
||||
* page's CSS and the Theme Builder cache.
|
||||
*
|
||||
* Fields in a body (E12, answer F4): the field references of the changed nodes – both forms Divi writes (ruling
|
||||
* Q11) – are checked against the post types of the templates that use the body; warnings only, nothing is
|
||||
* refused. Templates the user may not edit are only counted (ruling Q21 pattern). Read-only analysis.
|
||||
*/
|
||||
final class ThemeBuilderEdits
|
||||
{
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private VisibleEdits $visible = new VisibleEdits(),
|
||||
private ?DynamicFields $fields = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The live templates split by canEditTemplate() (ThemeBuilderAccess::editableLiveTemplates()) – computed once
|
||||
* per write and passed to prepare()/notice()/fieldWarnings().
|
||||
*
|
||||
* @return array{templates: list<Template>, hidden: list<Template>}
|
||||
*/
|
||||
public function live(): array
|
||||
{
|
||||
return $this->access->editableLiveTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live templates that show layout $page in its area (ruling Q22, Q21 pattern): "templates" the user may edit
|
||||
* (named in answers), "hidden" the number of the others (never named). Both together decide whether a write
|
||||
* needs confirm – a layout shown only by hidden templates is still visible.
|
||||
*
|
||||
* @param array{templates: list<Template>, hidden: list<Template>}|null $live live(); null = read now
|
||||
* @return array{templates: list<Template>, hidden: int}
|
||||
*/
|
||||
public function showing(DiviPage $page, ?array $live = null): array
|
||||
{
|
||||
$area = (string) $page->themeBuilderArea();
|
||||
$id = (int) $page->post->ID;
|
||||
$shows = static fn (Template $template): bool => $template->showsLayout($area, $id);
|
||||
$live ??= $this->live();
|
||||
|
||||
return [
|
||||
'templates' => array_values(array_filter($live['templates'], $shows)),
|
||||
'hidden' => count(array_filter($live['hidden'], $shows)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param array{templates: list<Template>, hidden: list<Template>}|null $live live(); null = read now
|
||||
* @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 $live = null): array|WP_Error
|
||||
{
|
||||
$showing = $this->showing($page, $live);
|
||||
$details = self::details($showing);
|
||||
if (! self::isShown($showing)) {
|
||||
return ['scope' => SaveScope::ThemeBuilderLayout, 'details' => $details];
|
||||
}
|
||||
|
||||
$id = (int) $page->post->ID;
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'Theme-Builder-Layout %d ist auf der Website sichtbar (%s, siehe templates); die Änderung wirkt '
|
||||
. 'sofort. Nichts wurde gespeichert (Probelauf).',
|
||||
$id,
|
||||
self::count($showing)
|
||||
),
|
||||
$details
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->visible->backupRefusal(
|
||||
$page->post,
|
||||
sprintf('Theme-Builder-Layout %d', $id),
|
||||
'Revisionen aktivieren (WP_POST_REVISIONS) oder das Layout im Divi Theme Builder bearbeiten.'
|
||||
);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
return [
|
||||
'scope' => SaveScope::ThemeBuilderLayout,
|
||||
'details' => array_merge($details, $this->visible->backup($page->post)),
|
||||
];
|
||||
}
|
||||
|
||||
/** A tool's own confirm_required (lit-divi-delete) on a visible layout, extended by the templates. */
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
if (Errors::codeOf($error) !== 'confirm_required') {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$showing = $this->showing($page);
|
||||
if (! self::isShown($showing)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
return VisibleEdits::merged(
|
||||
$error,
|
||||
sprintf('Das Layout ist auf der Website sichtbar (%s, siehe templates).', self::count($showing)),
|
||||
self::details($showing)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E12: in a body layout, the field references of the changed nodes checked against the post types of the
|
||||
* templates that use the layout (their singular conditions). Only templates the user may edit are named or
|
||||
* checked; others add a count (ruling Q21 pattern). Warnings only – the layout is saved anyway.
|
||||
*
|
||||
* @param array{templates: list<Template>, hidden: list<Template>}|null $live live(); null = read now
|
||||
* @return list<string>
|
||||
*/
|
||||
public function fieldWarnings(DiviPage $page, DiviChange $change, ?array $live = null): array
|
||||
{
|
||||
if ($this->fields === null || $page->themeBuilderArea() !== 'body') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = self::keysIn($change->touched());
|
||||
if ($keys === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$id = (int) $page->post->ID;
|
||||
$uses = static fn (Template $template): bool => $template->layouts['body']['id'] === $id;
|
||||
$live ??= $this->live();
|
||||
$types = [];
|
||||
foreach (array_filter($live['templates'], $uses) as $template) {
|
||||
array_push($types, ...$template->postTypes());
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
$hidden = count(array_filter($live['hidden'], $uses));
|
||||
if ($types === [] && $hidden > 0) {
|
||||
return [sprintf(
|
||||
'Body-Layout %d nutzen nur Vorlagen ohne Berechtigung (%d) oder keine mit Bedingung '
|
||||
. '„singular:post_type:…“; ob die Felder %s dort existieren, ist ungeprüft.',
|
||||
$id,
|
||||
$hidden,
|
||||
implode(', ', $keys)
|
||||
)];
|
||||
}
|
||||
|
||||
$warnings = $this->checkKeys($keys, $types, sprintf('Body-Layout %d', $id));
|
||||
if ($hidden > 0) {
|
||||
$warnings[] = sprintf(
|
||||
'Weitere Vorlagen ohne Berechtigung nutzen dieses Layout: %d; ob die Felder %s für deren '
|
||||
. 'Inhaltstypen passen, ist ungeprüft.',
|
||||
$hidden,
|
||||
implode(', ', $keys)
|
||||
);
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field references ({{field:<key>}} = custom_meta_<key>, and Divi's dialog form post_meta_key with
|
||||
* select_meta_key – ruling Q11) in $blocks and their descendants against $postTypes.
|
||||
*
|
||||
* @param list<Block> $blocks
|
||||
* @param list<string> $postTypes
|
||||
* @param string $what Names the layout in the warning ("Das neue Body-Layout").
|
||||
* @return list<string>
|
||||
*/
|
||||
public function checkFields(array $blocks, array $postTypes, string $what): array
|
||||
{
|
||||
if ($this->fields === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = self::keysIn($blocks);
|
||||
|
||||
return $keys === [] ? [] : $this->checkKeys($keys, $postTypes, $what);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @param list<string> $postTypes
|
||||
* @return list<string>
|
||||
*/
|
||||
private function checkKeys(array $keys, array $postTypes, string $what): array
|
||||
{
|
||||
if ($this->fields === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($postTypes === []) {
|
||||
return [sprintf(
|
||||
'%s gehört zu keiner Vorlage mit Bedingung „singular:post_type:…“; ob die Felder %s dort existieren, '
|
||||
. 'ist ungeprüft (Bedingungen mit lit/tb-assign setzen).',
|
||||
$what,
|
||||
implode(', ', $keys)
|
||||
)];
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
foreach ($keys as $key) {
|
||||
$problems = [];
|
||||
$notes = [];
|
||||
foreach ($postTypes as $type) {
|
||||
$reason = $this->fields->reason($type, $key);
|
||||
if ($reason !== null) {
|
||||
$problems[] = $type . ': ' . $reason;
|
||||
continue;
|
||||
}
|
||||
$note = $this->fields->note($type, $key);
|
||||
if ($note !== null) {
|
||||
$notes[] = $type . ': ' . $note;
|
||||
}
|
||||
}
|
||||
if ($problems !== []) {
|
||||
$warnings[] = sprintf(
|
||||
'Feld „%s“ zeigt Divi womöglich nicht als Text (%s); passende Felder mit lit/divi-dynamic-sources.',
|
||||
$key,
|
||||
implode('; ', array_merge($problems, $notes))
|
||||
);
|
||||
} elseif ($notes !== []) {
|
||||
$warnings[] = sprintf('Feld „%s“: %s.', $key, implode('; ', $notes));
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Block> $blocks
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function keysIn(array $blocks): array
|
||||
{
|
||||
$keys = [];
|
||||
foreach ($blocks as $block) {
|
||||
self::collectKeys($block, $keys);
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/** @param list<string> $keys */
|
||||
private static function collectKeys(Block $block, array &$keys): void
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
array_walk_recursive($attrs, static function (mixed $value) use (&$keys): void {
|
||||
$key = is_string($value) ? DynamicFields::metaKeyOf($value) : null;
|
||||
if ($key !== null) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
self::collectKeys($child, $keys);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array{templates: list<Template>, hidden: int} $showing */
|
||||
private static function isShown(array $showing): bool
|
||||
{
|
||||
return $showing['templates'] !== [] || $showing['hidden'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer fields: "templates" always (also [] – DiviReplaceTextAbility tells a layout by it), and the
|
||||
* hidden count only when there are any.
|
||||
*
|
||||
* @param array{templates: list<Template>, hidden: int} $showing
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function details(array $showing): array
|
||||
{
|
||||
return ['templates' => self::listing($showing['templates'])]
|
||||
+ ($showing['hidden'] > 0 ? ['hidden_templates' => $showing['hidden']] : []);
|
||||
}
|
||||
|
||||
/** @param array{templates: list<Template>, hidden: int} $showing */
|
||||
private static function count(array $showing): string
|
||||
{
|
||||
$total = count($showing['templates']) + $showing['hidden'];
|
||||
$text = sprintf('%d aktive %s', $total, $total === 1 ? 'Vorlage' : 'Vorlagen');
|
||||
|
||||
return $showing['hidden'] > 0
|
||||
? sprintf(
|
||||
'%s, davon %d ohne Berechtigung – nicht aufgeführt, siehe hidden_templates',
|
||||
$text,
|
||||
$showing['hidden']
|
||||
)
|
||||
: $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Template> $templates
|
||||
* @return list<array{id: int, title: string, use_on: list<string>}>
|
||||
*/
|
||||
private static function listing(array $templates): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Template $template): array => [
|
||||
'id' => $template->id,
|
||||
'title' => $template->title,
|
||||
'use_on' => $template->useOn,
|
||||
],
|
||||
$templates
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The group "Divi Theme Builder" (Etappe 3b): lit/tb-list, lit/tb-create-template, lit/tb-assign and
|
||||
* lit/tb-activate, all indirect. Built by DiviIntegration::themeBuilder() so it shares the Theme Builder access
|
||||
* (rights, Tools-tab gates – its id ThemeBuilderAccess::GROUP is what the gates of the Divi tools ask; once this
|
||||
* group is registered, its mode and "nur lesen" switch in the Tools tab drive them). Available with the Divi 5
|
||||
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log serve
|
||||
* the writing tools.
|
||||
*/
|
||||
final class ThemeBuilderIntegration implements Integration
|
||||
{
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private DiviComposition $composition,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DynamicFields $fields
|
||||
) {
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return ThemeBuilderAccess::GROUP;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __('Divi Theme Builder', 'wp-lit-mcp');
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return DiviModules::isAvailable();
|
||||
}
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
$conditions = new TemplateConditions($this->access->store());
|
||||
|
||||
return [
|
||||
new TbListAbility($this->access, $conditions),
|
||||
new TbCreateTemplateAbility(
|
||||
$this->access,
|
||||
$conditions,
|
||||
$this->composition,
|
||||
$this->saver,
|
||||
$this->log,
|
||||
new ThemeBuilderEdits($this->access, fields: $this->fields)
|
||||
),
|
||||
new TbAssignAbility($this->access, $conditions, $this->log),
|
||||
new TbActivateAbility($this->access, $conditions, $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -141,10 +141,7 @@ final class PostGuard
|
||||
$name = is_string($postType) ? $postType : '';
|
||||
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
|
||||
if (! is_object($object)) {
|
||||
return Errors::notFound(
|
||||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
|
||||
'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.'
|
||||
);
|
||||
return self::unavailableType($name);
|
||||
}
|
||||
|
||||
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
|
||||
@@ -155,6 +152,15 @@ final class PostGuard
|
||||
return $object;
|
||||
}
|
||||
|
||||
/** The answer for a post type that is not available to the tools – the same for unknown and excluded ones. */
|
||||
public static function unavailableType(string $name): WP_Error
|
||||
{
|
||||
return Errors::notFound(
|
||||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
|
||||
'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a list/search query for this post type must stay restricted to the current
|
||||
* user's own entries because they lack edit_others_posts. Filtering per row after the
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Render;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
@@ -22,8 +23,15 @@ 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)
|
||||
{
|
||||
/**
|
||||
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether global module IDs may be named
|
||||
* (library access of this connection); null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PageRenderer $renderer,
|
||||
private ?Closure $globalIdsVisible = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -108,13 +116,23 @@ final class PageRenderSummaryAbility implements Ability
|
||||
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
|
||||
}
|
||||
|
||||
foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) {
|
||||
$globalIds = self::globalModuleIds((string) $post->post_content);
|
||||
if ($globalIds !== [] && $this->globalIdsVisible !== null && ! ($this->globalIdsVisible)()) {
|
||||
$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
|
||||
'%d %s im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi globale Module '
|
||||
. 'nicht; die Divi-Bibliothek ist für dieses Konto nicht zugänglich).',
|
||||
count($globalIds),
|
||||
count($globalIds) === 1 ? 'globales Modul ist' : 'globale Module sind'
|
||||
);
|
||||
} else {
|
||||
foreach (array_slice($globalIds, 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(
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
@@ -21,8 +22,8 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -48,11 +49,16 @@ defined('ABSPATH') || exit;
|
||||
*/
|
||||
final class WordPressIntegration implements Integration
|
||||
{
|
||||
/** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
|
||||
/**
|
||||
* @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration).
|
||||
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
|
||||
* global module IDs (the Divi integration's library access); null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private ChangeLog $log,
|
||||
private ?ContentComposer $divi = null
|
||||
private ?ContentComposer $divi = null,
|
||||
private ?Closure $globalIdsVisible = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -99,7 +105,7 @@ final class WordPressIntegration implements Integration
|
||||
new MenuAssignLocationAbility($this->log),
|
||||
new WpSettingsGetAbility($whitelist),
|
||||
new WpSettingsUpdateAbility($whitelist, $this->log),
|
||||
new PageRenderSummaryAbility($this->schema, new PageRenderer()),
|
||||
new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -24,6 +24,7 @@ use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
@@ -44,7 +45,7 @@ defined('ABSPATH') || exit;
|
||||
*/
|
||||
final class Plugin
|
||||
{
|
||||
public const VERSION = '0.4.0';
|
||||
public const VERSION = '0.5.0';
|
||||
|
||||
private static string $pluginFile = '';
|
||||
private static ?SettingsPage $settingsPage = null;
|
||||
@@ -95,20 +96,35 @@ final class Plugin
|
||||
&& $policy instanceof ExposurePolicy
|
||||
&& $policy->allows('lit/library-insert', $registry->abilities());
|
||||
};
|
||||
// Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
|
||||
$themeBuilderVisible = static function () use (&$policy): bool {
|
||||
return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
|
||||
};
|
||||
$themeBuilderWritable = static function () use (&$policy): bool {
|
||||
return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
|
||||
};
|
||||
$divi = new DiviIntegration(
|
||||
$schema,
|
||||
$changeLog,
|
||||
$libraryWritable,
|
||||
libraryVisible: $libraryVisible,
|
||||
diviWritable: $diviWritable,
|
||||
libraryInsertable: $libraryInsertable
|
||||
libraryInsertable: $libraryInsertable,
|
||||
themeBuilderWritable: $themeBuilderWritable,
|
||||
themeBuilderVisible: $themeBuilderVisible
|
||||
);
|
||||
$registry = new IntegrationRegistry(
|
||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||
new WordPressIntegration(
|
||||
$schema,
|
||||
$changeLog,
|
||||
$divi->contentComposer($diviWritable),
|
||||
$divi->libraryIdsVisible(...)
|
||||
),
|
||||
new MediaIntegration($schema, $changeLog, $limits),
|
||||
$divi,
|
||||
$divi->library()
|
||||
$divi->library(),
|
||||
$divi->themeBuilder()
|
||||
);
|
||||
|
||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||
|
||||
@@ -4345,6 +4345,92 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "divi/post-content",
|
||||
"title": "Post Content",
|
||||
"category": "module",
|
||||
"appearance": {
|
||||
"multiple": false,
|
||||
"multipleAliases": [
|
||||
"divi/fullwidth-post-content"
|
||||
],
|
||||
"themeBuilderLayouts": [
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"attributes": {
|
||||
"module": {
|
||||
"settings": {
|
||||
"meta": {
|
||||
"meta": []
|
||||
},
|
||||
"advanced": {
|
||||
"html": []
|
||||
},
|
||||
"decoration": {
|
||||
"animation": [],
|
||||
"attributes": [],
|
||||
"background": [],
|
||||
"bodyFont": [],
|
||||
"border": [],
|
||||
"boxShadow": [],
|
||||
"conditions": [],
|
||||
"disabledOn": [],
|
||||
"filters": [],
|
||||
"interactions": [],
|
||||
"headingFont": [],
|
||||
"overflow": [],
|
||||
"order": [],
|
||||
"position": [],
|
||||
"scroll": [],
|
||||
"sizing": [],
|
||||
"spacing": [],
|
||||
"sticky": [],
|
||||
"transform": [],
|
||||
"zIndex": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"settings": {
|
||||
"decoration": {
|
||||
"border": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 10,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/border",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boxShadow": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 15,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/box-shadow",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
@@ -4887,6 +4973,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"divi/post-content": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-st
|
||||
|
||||
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.
|
||||
|
||||
Seit den Etappe-3b-Fixtures (`stage3b/`, 2026-09-27) steht deren Vorlage 2394 ebenfalls in der Live-Liste; ein neuer Export ergäbe ein anderes `theme-builder.json`. Diesen Ordner daher nur auf einer Site ohne die 3b-Fixtures neu exportieren (Ruling B21).
|
||||
|
||||
## Aufräumen (lokal)
|
||||
|
||||
Die Daten bleiben vorerst zum Ansehen stehen. Zum Entfernen (DDEV, WordPress-Wurzel; IDs vorher mit `manifest.json` abgleichen):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Divi-5-Fixtures für Etappe 3b (Theme Builder mit Pods)
|
||||
|
||||
Echte Daten aus Divi 5.2.1 und Pods 3.3.9.2, erzeugt am 2026-09-27 auf der lokalen DDEV-Site (`https://wordpress.ddev.site:8443`). Vorlage und Body-Layout sind **über die Oberfläche** entstanden: echter Theme-Builder-Editor und der darin geöffnete Visual Builder in einem headless Chrome (per DevTools-Protokoll gesteuert, echte Mausklicks und Tastatureingaben, angemeldet als Administrator). Anders als in `stage3/` wurde kein Inhalt per `setContent` gesetzt und keine TB-Nutzlast nachgebaut: Module, Dynamic Content, Admin-Labels, Titel, Bedingungen und der Aktiv-Schalter kamen aus Divis eigenen Dialogen. Plugin-Code hat nichts geschrieben.
|
||||
|
||||
Pfade zu Divi relativ zu `wp-content/themes/Divi/includes/`. Die TB-App ist `builder/frontend-builder/build/theme-builder.js` (minifiziert, dort steht ein Suchbegriff statt einer Zeile).
|
||||
|
||||
## Dateien
|
||||
|
||||
Aufbau wie `stage3/`: je Post `<rolle>.json` (Post-Zeile, alle Meta roh in Speicherreihenfolge, Terme) und `<rolle>.html` mit `post_content` byte-genau; `manifest.json` (Rolle → ID), `theme-builder.json` (Live-TB-Post mit Vorlagenliste, `et_tb_*`-Optionen, als ungenutzt markierte Posts), dazu `render--person-erika.txt` (sichtbare Ausgabe, siehe Schritt 4).
|
||||
|
||||
| Rolle | ID | Typ | Inhalt |
|
||||
|---|---|---|---|
|
||||
| `lit_fixture_person--erika-muster` | 2385 | `lit_fixture_person` | `role` = „Geschäftsführung“, `level` = `senior`, kein Mentor |
|
||||
| `lit_fixture_person--max-beispiel` | 2386 | `lit_fixture_person` | `role` = „Entwicklung“, `level` = `junior`, `mentor` = 2385 |
|
||||
| `tb-body--person-body` | 2391 | `et_body_layout` | Überschrift „Person Titel“ (Beitragstitel), Texte „Person Funktion“/„Person Stufe“/„Person Mentor“ (Pods `role`/`level`/`mentor`), Modul „Person Beitragsinhalt“ (`divi/post-content`) |
|
||||
| `tb-template--person` | 2394 | `et_template` | „[Fixture 3b] Person“, **deaktiviert**, Header = globaler Header 1061, Body = 2391, kein Footer, Bedingung `singular:post_type:lit_fixture_person:all` |
|
||||
| (theme-builder.json) | 1064 | `et_theme_builder` | Live-TB-Post, `_et_template` = [1065, 1066, 2394] |
|
||||
|
||||
Nicht exportiert: die Pods-Definition (Pod 2381, Felder 2382–2384), die Revisionen 2392/2393 des Body-Layouts, die Standardvorlage 1065 und der Header 1061 (liegen in `stage3/`).
|
||||
|
||||
## Wie die Fixtures entstanden sind
|
||||
|
||||
### 1. Pod und Personen (Ruling Q1)
|
||||
|
||||
`wp eval-file …/tests/Integration/pods-fixture.php fixture` legt den eigenen Pod `lit_fixture_person` („[Fixture] Personen“, Post-Typ, Speicherung `meta`, öffentlich) mit `role` (Text), `level` (Auswahl `junior|Junior`, `senior|Senior`) und `mentor` (Beziehung auf `lit_fixture_person`, einzeln) an und danach die zwei Personen über `pods()->add()`. Der Aufruf ist wiederholbar (legt nichts doppelt an). `lit_person` (Pod der wp-basis-smoke) bleibt unberührt; `pods-fixture.php delete` löscht nur `lit_person`. Personen haben `post_author` 0, weil WP-CLI ohne Benutzer lief.
|
||||
|
||||
`et_builder_enabled_for_post_type('lit_fixture_person')` liefert „divi aus“. Die Divi-Einstellung „Beitragstyp-Integration“ wurde **nicht** geändert (Ruling Q5); der Theme Builder braucht sie nicht, die Bedingung „[Fixture] Personen“ bot er trotzdem an.
|
||||
|
||||
Vor dem ersten Live-Speichern gab es keine verwaisten TB-Posts: `_et_template` von 1064 war [1065, 1066], kein Post trug `_et_theme_builder_marked_as_unused` (B5).
|
||||
|
||||
### 2. Vorlage im Theme-Builder-Editor (`wp-admin/admin.php?page=et_theme_builder`)
|
||||
|
||||
1. „Neue Vorlage hinzufügen“ → „Neue Vorlage erstellen“ → im Dialog „Template-Einstellungen“ unter „[Fixture] Personen“ den Haken „Alle [Fixture] Personen“ → „Vorlage erstellen“. Divi setzt dabei selbst „Globaler Header“ (1061) als Kopfzeile und den Titel „Alle [Fixture] Personen“ (`autogenerated_title` 1); die neue Vorlage ist **aktiv** (`enabled` 1).
|
||||
2. Hauptinhalt: „Benutzerdefinierten Hauptinhalt erstellen“ → `et_theme_builder_api_create_layout` (`builder/frontend-builder/theme-builder/api.php:12-46`) → 2391, dann `et_theme_builder_api_get_layout_url` (`api.php:116-147`); der Editor öffnet den Visual Builder als Overlay (`iframe#et-fb-app-frame`, `…?et_fb=1&et_tb=1&app_window=1`).
|
||||
3. Im Visual Builder (Schritt 3), danach „Beenden“.
|
||||
4. Menü „⋮“ der Vorlage → „Template Deaktivieren“, „Vorlage umbenennen“ → „[Fixture 3b] Person“; Fußzeile leer gelassen.
|
||||
5. „Änderungen Speichern“ → `et_theme_builder_api_save` mit `live=1` (`api.php:160-273` → `et_theme_builder_store_template`, `builder/frontend-builder/theme-builder/theme-builder.php:1463`). Die neue Vorlage bekam dabei die ID 2394 (die UUID aus dem Editor wurde ersetzt).
|
||||
|
||||
### 3. Body-Layout im Visual Builder (2391)
|
||||
|
||||
Der leere Abschnitt kam von Divi. Dann über die Oberfläche: grünes „+“ → „Zeile einfügen“ → eine Spalte; Modulsuche „Überschrift“; danach über „Spalte → Elemente → Element hinzufügen“ dreimal „Text“ und einmal „Inhalt posten“ (so heißt `divi/post-content` in der deutschen Modulliste). Je Modul im Einstellungsdialog:
|
||||
|
||||
- Überschrift: Knopf „Dynamic Content“ am Feld „Überschrift“ → „Beitrag/Archivtitel“ → „Anwenden“; Meta → Elementbeschriftung „Person Titel“.
|
||||
- Texte: Knopf „Dynamic Content“ am Feld „Fließtext“ → „Benutzerdefiniertes Feld“ → Auswahl „Benutzerdefiniertes Feld auswählen“ → unter „Standard-Meta-Keys“ `role` bzw. `level` bzw. `mentor` → „Anwenden“; Elementbeschriftung „Person Funktion“/„Person Stufe“/„Person Mentor“. Die Pods-Felder standen in der Liste, eine manuelle Eingabe des Schlüssels war nicht nötig.
|
||||
- „Inhalt posten“: Elementbeschriftung „Person Beitragsinhalt“.
|
||||
- Seiteneinstellungen (Zahnrad → „Seiteneinstellungen“) → Titel „[Fixture 3b] Person Body“; „Speichern“ → `POST divi/v1/sync-to-server` mit `post_status: publish` (wie in `stage3/`).
|
||||
|
||||
Beim Text „Person Stufe“ wurde zuerst versehentlich „Beitrag/Archivtitel“ gewählt, dann mit „Reset field“ zurückgesetzt und neu gewählt. Deshalb steht dort `builderVersion`/`module` vor `content` – die Schlüsselreihenfolge spiegelt die Bearbeitungsreihenfolge, der Inhalt ist identisch mit den anderen beiden Texten.
|
||||
|
||||
### 4. Darstellung (Ruling Q5, Vorlage kurz aktiv)
|
||||
|
||||
Im TB-Editor: Bedingung auf genau eine Person eingeschränkt („Bestimmte [Fixture] Personen“ → nur „[Fixture 3b] Erika Muster“, `singular:post_type:lit_fixture_person:id:2385`), „Template Aktivieren“, „Änderungen Speichern“. Ein Wächter im Browser hätte jedes Live-Speichern abgebrochen, in dem eine aktive Vorlage etwas anderes als genau diese Bedingung trägt; ein Notfall-Skript (`_et_enabled` = 0, Bedingung zurück auf „alle“, Cache leeren) lief im `finally` mit. Dann Seiten abgerufen (`?p=2385`, `?p=2386`, Startseite), sofort „Template Deaktivieren“ + Speichern und die Bedingung wieder auf „Alle [Fixture] Personen“ + Speichern. Die Vorlage war etwa zwei Minuten aktiv.
|
||||
|
||||
Geprüft: `_et_enabled` von 2394 = `0`; während der aktiven Zeit enthielten Max (2386) und die Startseite keine `_tb_body`/`_tb_header`-Module; danach enthalten weder Erika noch Max, die Startseite noch die Seite 1053 ein TB-Modul.
|
||||
|
||||
`render--person-erika.txt` ist der sichtbare Text der fünf Module aus dem HTML von `?p=2385` (je Zeile „Admin-Label: Text“).
|
||||
|
||||
### 5. Befund 10 (Schritt 9)
|
||||
|
||||
Tab A: TB-Editor frisch geladen (drei Vorlagen). Im Terminal eine deaktivierte Vorlage „[Fixture 3b] Stale-Test“ (2395) so angehängt wie `DiviThemeBuilder::attach()` (Backup-Option, `add_post_meta(1064, '_et_template', 2395)`, Backup löschen) → Liste [1065, 1066, 2394, 2395]. In Tab A „[Fixture 3b] Person“ umbenannt und zurückbenannt, „Änderungen Speichern“. Danach 2395 und die TB-Entwurfsreste (siehe Befund 11) mit `wp post delete --force` gelöscht; `_et_template` = [1065, 1066, 2394].
|
||||
|
||||
## Was echt ist und was nachgebildet wurde
|
||||
|
||||
**Echt aus Divi (Oberfläche + Server):** Blockstruktur und Attribute aller Module (auch die Standardattribute beim Einfügen, z. B. `columnStructure`/`flexColumnStructure` der Zeile), die Dynamic-Content-Werte mit ihren `settings`, Admin-Labels, Titel, alle Meta des Body-Layouts und der Vorlage, `_et_template` am TB-Post, die Bedingung, der Aktiv-Schalter.
|
||||
|
||||
**Nachgebildet bzw. nicht aus Divi:** nur die Zuordnung der Stale-Test-Vorlage in Befund 10 (nachgebaut nach `DiviThemeBuilder::attach()`) und die Pods-Daten (über die Pods-API, nicht die Pods-Oberfläche). Das Notfall-Skript aus Schritt 4 hat nach Divis Speichern noch einmal `_et_use_on` gelöscht und neu gesetzt (gleicher Wert); das spätere Speichern in Befund 10 hat die Vorlage 2394 wieder über `et_theme_builder_store_template` geschrieben, die exportierten Meta sind also Divis eigene.
|
||||
|
||||
## Befunde
|
||||
|
||||
**Befund 10 – ein alter TB-Editor entfernt fremd angehängte Vorlagen aus der Liste.** Beobachtet: Nach dem Speichern in Tab A stand 2395 **nicht mehr** in `_et_template` (Liste [1065, 1066, 2394]), und 2395 bekam `_et_theme_builder_marked_as_unused` = `2026-09-27 12:47:02`. Divi hätte den Post nach sieben Tagen beim nächsten TB-Speichern in den Papierkorb gelegt. Ursache im Code: `et_theme_builder_api_save()` löscht beim letzten Teil-Request die komplette Zuordnung (`api.php:233`, `delete_post_meta( $theme_builder_id, '_et_template' )`) und schreibt nur die Vorlagen, die der Editor mitschickt (`api.php:238-240`); der Editor schickt seinen beim Laden übernommenen Stand (`et_theme_builder_bundle.config.templates.live`). Bei `live=1` folgt `et_theme_builder_trash_draft_and_unused_posts()` (`api.php:245-247` → `theme-builder.php:591-678`): alle TB-Vorlagen und -Layouts, die die neue Liste nicht referenziert, werden markiert (`theme-builder.php:646`), markierte Posts älter als sieben Tage in den Papierkorb gelegt (`theme-builder.php:651-676`). Der Editor zeigt die fremde Vorlage nicht an und warnt nicht. Folge für das Plugin: Jede über MCP angehängte Vorlage (und ihre Layouts) geht verloren, sobald jemand einen vorher geöffneten TB-Editor speichert – Divi hebt die Markierung nur wieder auf, wenn die Vorlage erneut in der Liste steht (`theme-builder.php:620-623`).
|
||||
|
||||
**Befund 11 – der TB-Entwurf entsteht erst durch Divis Autosave.** Öffnen des Editors: kein Entwurf. Erste ungespeicherte Änderung (neue Vorlage): sofort noch kein Entwurf. Divi speichert Entwürfe per Intervall alle 120 s, wenn es ungespeicherte Änderungen gibt (`theme-builder.js`, Suche `e.autosave=setInterval`, `12e4`, `save({draft:!0})` → `et_theme_builder_api_save` mit `live=0`). Beim ersten Tick entstand der Entwurfs-TB-Post 2387 (`auto-draft`) mit **Kopien aller Vorlagen** als eigene `et_template`-Posts (2388–2390, Status `publish`, die Kopie der neuen Vorlage mit `enabled` 1). Weitere Ticks aktualisierten dieselben Posts. Das Live-Speichern legte 2387 in den Papierkorb (`theme-builder.php:604`) und markierte 2388–2390 als ungenutzt. Das neue Body-Layout 2391 entstand unabhängig davon beim Klick „Benutzerdefinierten Hauptinhalt erstellen“, also schon vor jedem Speichern. Solange ein Entwurf besteht, zeigt der Editor beim nächsten Öffnen dessen Stand (`config.templates.hasDraft`).
|
||||
|
||||
**Befund 12 – Form der Pods-Felder im Body.** Der echte Dialog schreibt für ein aus der Liste gewähltes Pods-Feld **weder** `custom_meta_role` als Namen **noch** `post_meta_key` mit `meta_key` `role`, sondern:
|
||||
|
||||
```
|
||||
$variable({"type":"content","value":{"name":"post_meta_key","settings":{"before":"","after":"","select_meta_key":"custom_meta_role","meta_key":"","date_format":"default","custom_date_format":"","enable_html":"off"}}})$
|
||||
```
|
||||
|
||||
(in `post_content` steht `\u0022` statt `"`, `tb-body--person-body.html`). Gleiches für `level`/`mentor`. `meta_key` ist leer, `date_format`/`custom_date_format`/`enable_html` werden immer mitgeschrieben. Der Beitragstitel hat nur `{"before":"","after":""}`. Divi nennt diese Form im Server „Legacy complex format“ und löst sie über `select_meta_key` auf (`builder-5/server/Packages/Module/Layout/Components/DynamicContent/DynamicContentOptionPostMetaKey.php:236-262` bzw. `:278-296`); `custom_meta_<key>` als Name heißt dort „Simple format (preferred)“ (`:225-235`). Ein aus der Liste gewähltes Feld gilt nicht als manuelle Eingabe (`DynamicContentUtils.php:527-537`) und braucht im Builder das Recht `read_dynamic_content_custom_fields` (`DynamicContentOptionPostMetaKey.php:305-316`), im Frontend nicht. Die Liste „Standard-Meta-Keys“ stammt aus `DynamicContentOptions::get_most_used_meta_keys()` (`DynamicContentOptions.php:286-316`): Meta-Schlüssel ohne führendes `_` mit nicht serialisierten Werten aus öffentlichen Post-Typen der ganzen Site, 5 Minuten zwischengespeichert – nicht die Felder des Pods; `_pods_mentor` (serialisiert) fehlt deshalb, `mentor` steht drin. Das Plugin schreibt selbst die Namensform `custom_meta_<key>` (`src/Integrations/Divi/Compose/Placeholders.php:196`) und prüft `select_meta_key`-Werte bereits (`src/Integrations/Divi/Compose/DynamicContentRules.php:175-180`). Wer im Body nach „dem Feld role“ sucht oder es ändert, muss beide Formen erkennen.
|
||||
|
||||
Ausgabe laut `render--person-erika.txt`: `role` → „Geschäftsführung“; `level` → **`senior`** (gespeicherter Wert, nicht die Beschriftung „Senior“); `mentor` bei Erika leer (sie hat keinen Mentor; Divi gibt im Frontend bei leerem Wert nichts aus, `DynamicContentOptionPostMetaKey.php:433`). Für Max wurde die Vorlage nicht aktiviert (Q5: genau eine Person); Divis Resolver liefert dort nur lesend aufgerufen (`DynamicContentUtils::get_resolved_value`, Kontext `display`) für `mentor` **`2385`** – die rohe Beitrags-ID, keinen Titel (F26).
|
||||
|
||||
## Wirkung auf die Live-Site
|
||||
|
||||
Die Vorlage 2394 ist deaktiviert (`_et_enabled` = 0); `ET_Theme_Builder_Request::get_template()` überspringt sie (`builder/frontend-builder/theme-builder/ThemeBuilderRequest.php:393`). Sie wäre zudem nur für Fixture-Personen zuständig. Die Standardvorlage 1065 und 1066 blieben deaktiviert. Stale-Test 2395 und die Entwurfsreste 2387–2390 sind gelöscht, kein Post ist als ungenutzt markiert, es gibt keinen TB-Entwurf. Die Divi-Einstellung „Beitragstyp-Integration“ ist unverändert. Neu und sichtbar sind nur die zwei öffentlichen Fixture-Personen unter `/lit-fixture-person/…` und der Menüpunkt „[Fixture] Personen“ im Backend.
|
||||
|
||||
## Neu exportieren
|
||||
|
||||
```
|
||||
ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]"
|
||||
```
|
||||
|
||||
Exportiert werden Posts mit Titel „[Fixture 3b] …“ (auch vom Typ `lit_fixture_person`). Das Skript löscht vorher alle `*.json`/`*.html` in diesem Ordner – `render--person-erika.txt` und diese README bleiben. Ohne Argumente exportiert es wie bisher `stage3/`; dessen `theme-builder.json` enthält seit diesen Fixtures auch 2394 (siehe `stage3/README.md`).
|
||||
|
||||
## Aufräumen (lokal)
|
||||
|
||||
Die Fixtures bleiben stehen. Zum Entfernen (DDEV, WordPress-Wurzel; IDs vorher mit `manifest.json` abgleichen):
|
||||
|
||||
```
|
||||
# Vorlage aus der Live-Liste nehmen, dann Vorlage und Body-Layout (Revisionen fallen mit weg)
|
||||
ddev wp eval 'delete_post_meta(1064, "_et_template", "2394");'
|
||||
ddev wp post delete 2394 2391 --force
|
||||
# Personen und Pod-Definition (Pod 2381, Felder 2382–2384)
|
||||
ddev wp post delete 2385 2386 --force
|
||||
ddev wp eval 'pods_api()->delete_pod(["name" => "lit_fixture_person"], false, true);'
|
||||
# Divi-Caches
|
||||
ddev wp eval 'et_theme_builder_clear_wp_cache("all"); ET_Core_PageResource::remove_static_resources("all", "all");'
|
||||
```
|
||||
|
||||
Nicht mit dem TB-Editor aufräumen, solange die `stage3/`-Fixtures noch gebraucht werden: Speichern im Editor schreibt die ganze Liste neu (Befund 10).
|
||||
@@ -0,0 +1 @@
|
||||
<p>Beitragsinhalt der Fixture-Person Erika Muster.</p>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"post": {
|
||||
"ID": 2385,
|
||||
"post_type": "lit_fixture_person",
|
||||
"post_status": "publish",
|
||||
"post_title": "[Fixture 3b] Erika Muster",
|
||||
"post_name": "fixture-3b-erika-muster",
|
||||
"post_author": 0,
|
||||
"post_parent": 0,
|
||||
"menu_order": 0,
|
||||
"post_excerpt": "",
|
||||
"post_date_gmt": "2026-09-27 12:27:46",
|
||||
"post_modified_gmt": "2026-09-27 12:27:46",
|
||||
"content_file": "lit_fixture_person--erika-muster.html",
|
||||
"content_sha256": "1ba6ea6d3a4ca9904d75150f03820fbe3df63123159f661fe00630f0f75d3eeb"
|
||||
},
|
||||
"meta": {
|
||||
"role": [
|
||||
"Geschäftsführung"
|
||||
],
|
||||
"level": [
|
||||
"senior"
|
||||
]
|
||||
},
|
||||
"terms": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<p>Beitragsinhalt der Fixture-Person Max Beispiel.</p>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"post": {
|
||||
"ID": 2386,
|
||||
"post_type": "lit_fixture_person",
|
||||
"post_status": "publish",
|
||||
"post_title": "[Fixture 3b] Max Beispiel",
|
||||
"post_name": "fixture-3b-max-beispiel",
|
||||
"post_author": 0,
|
||||
"post_parent": 0,
|
||||
"menu_order": 0,
|
||||
"post_excerpt": "",
|
||||
"post_date_gmt": "2026-09-27 12:27:46",
|
||||
"post_modified_gmt": "2026-09-27 12:27:46",
|
||||
"content_file": "lit_fixture_person--max-beispiel.html",
|
||||
"content_sha256": "9724e93063332c903dcc04e82b9a9b956da5b61f0d3321ca5e7b0886164090da"
|
||||
},
|
||||
"meta": {
|
||||
"role": [
|
||||
"Entwicklung"
|
||||
],
|
||||
"level": [
|
||||
"junior"
|
||||
],
|
||||
"_pods_mentor": [
|
||||
"a:1:{i:0;i:2385;}"
|
||||
],
|
||||
"mentor": [
|
||||
"2385"
|
||||
]
|
||||
},
|
||||
"terms": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"divi_version": "5.2.1",
|
||||
"posts": {
|
||||
"lit_fixture_person--erika-muster": 2385,
|
||||
"lit_fixture_person--max-beispiel": 2386,
|
||||
"tb-body--person-body": 2391,
|
||||
"tb-template--person": 2394
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Person Titel: [Fixture 3b] Erika Muster
|
||||
Person Funktion: Geschäftsführung
|
||||
Person Stufe: senior
|
||||
Person Mentor:
|
||||
Person Beitragsinhalt: Beitragsinhalt der Fixture-Person Erika Muster.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} -->
|
||||
<!-- wp:divi/row {"module":{"advanced":{"columnStructure":{"desktop":{"value":"4_4"}},"flexColumnStructure":{"desktop":{"value":"equal-columns_1"}}},"decoration":{"layout":{"desktop":{"value":{"flexWrap":"nowrap"}}}}},"builderVersion":"5.2.1"} -->
|
||||
<!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"4_4"}}},"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
|
||||
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Person Titel"}}}},"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}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||
|
||||
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Person Funktion"}}}},"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_role\u0022,\u0022meta_key\u0022:\u0022\u0022,\u0022date_format\u0022:\u0022default\u0022,\u0022custom_date_format\u0022:\u0022\u0022,\u0022enable_html\u0022:\u0022off\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||
|
||||
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Person Stufe"}}}},"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_level\u0022,\u0022meta_key\u0022:\u0022\u0022,\u0022date_format\u0022:\u0022default\u0022,\u0022custom_date_format\u0022:\u0022\u0022,\u0022enable_html\u0022:\u0022off\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||
|
||||
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Person Mentor"}}}},"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_mentor\u0022,\u0022meta_key\u0022:\u0022\u0022,\u0022date_format\u0022:\u0022default\u0022,\u0022custom_date_format\u0022:\u0022\u0022,\u0022enable_html\u0022:\u0022off\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
|
||||
|
||||
<!-- wp:divi/post-content {"module":{"meta":{"adminLabel":{"desktop":{"value":"Person Beitragsinhalt"}}}},"builderVersion":"5.2.1"} /-->
|
||||
<!-- /wp:divi/column -->
|
||||
<!-- /wp:divi/row -->
|
||||
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"post": {
|
||||
"ID": 2391,
|
||||
"post_type": "et_body_layout",
|
||||
"post_status": "publish",
|
||||
"post_title": "[Fixture 3b] Person Body",
|
||||
"post_name": "theme-builder-layout-2",
|
||||
"post_author": 1,
|
||||
"post_parent": 0,
|
||||
"menu_order": 0,
|
||||
"post_excerpt": "",
|
||||
"post_date_gmt": "2026-09-27 12:31:20",
|
||||
"post_modified_gmt": "2026-09-27 12:40:46",
|
||||
"content_file": "tb-body--person-body.html",
|
||||
"content_sha256": "14037fe5c0e032c19ab9015272464d1dcd887bf08630972ef05dc20aae04e203"
|
||||
},
|
||||
"meta": {
|
||||
"_et_pb_use_builder": [
|
||||
"on"
|
||||
],
|
||||
"_et_pb_show_page_creation": [
|
||||
"off"
|
||||
],
|
||||
"_et_pb_ab_current_shortcode": [
|
||||
"[et_pb_split_track id=\"2391\" /]"
|
||||
],
|
||||
"_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 3b] Person Body"
|
||||
],
|
||||
"_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:\"0e37b4ab-8f7e-488f-b9cf-7ae508b71ad6\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}"
|
||||
],
|
||||
"_et_pb_use_divi_5": [
|
||||
"on"
|
||||
]
|
||||
},
|
||||
"terms": {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"post": {
|
||||
"ID": 2394,
|
||||
"post_type": "et_template",
|
||||
"post_status": "publish",
|
||||
"post_title": "[Fixture 3b] Person",
|
||||
"post_name": "fixture-3b-person",
|
||||
"post_author": 1,
|
||||
"post_parent": 0,
|
||||
"menu_order": 0,
|
||||
"post_excerpt": "",
|
||||
"post_date_gmt": "2026-09-27 12:42:26",
|
||||
"post_modified_gmt": "2026-09-27 12:42:26",
|
||||
"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": [
|
||||
"2391"
|
||||
],
|
||||
"_et_body_layout_enabled": [
|
||||
"1"
|
||||
],
|
||||
"_et_footer_layout_id": [
|
||||
"0"
|
||||
],
|
||||
"_et_footer_layout_enabled": [
|
||||
"1"
|
||||
],
|
||||
"_et_onboarding_created": [
|
||||
"0"
|
||||
],
|
||||
"_et_use_on": [
|
||||
"singular:post_type:lit_fixture_person:all"
|
||||
]
|
||||
},
|
||||
"terms": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"theme_builder_post": {
|
||||
"ID": 1064,
|
||||
"post_status": "publish",
|
||||
"post_title": "Theme Builder",
|
||||
"meta": {
|
||||
"_et_template": [
|
||||
"1065",
|
||||
"1066",
|
||||
"2394"
|
||||
]
|
||||
}
|
||||
},
|
||||
"template_ids_in_order": [
|
||||
1065,
|
||||
1066,
|
||||
2394
|
||||
],
|
||||
"draft_theme_builder_posts": [],
|
||||
"options_et_tb": {},
|
||||
"marked_as_unused": []
|
||||
}
|
||||
@@ -9,21 +9,24 @@
|
||||
*
|
||||
* Usage (DDEV, WordPress root):
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]"
|
||||
* Arguments: target folder below tests/Fixtures/divi (default stage3) and title prefix (default "[Fixture]").
|
||||
*
|
||||
* Only posts titled "[Fixture] …" are exported, plus the Default Website Template when its
|
||||
* Only posts titled "<prefix> …" 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_stage = preg_match('/^[a-z0-9]+$/D', (string) ($args[0] ?? '')) === 1 ? (string) $args[0] : 'stage3';
|
||||
$lit_prefix = (string) ($args[1] ?? '[Fixture]') . ' ';
|
||||
$lit_dir = dirname(__DIR__) . '/Fixtures/divi/' . $lit_stage;
|
||||
$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));
|
||||
$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)
|
||||
@@ -33,6 +36,7 @@ $lit_role = static function (WP_Post $post, array $terms): string {
|
||||
'et_header_layout' => 'tb-header',
|
||||
'et_body_layout' => 'tb-body',
|
||||
'et_footer_layout' => 'tb-footer',
|
||||
'lit_fixture_person' => 'lit_fixture_person',
|
||||
][$post->post_type] ?? $post->post_type;
|
||||
|
||||
return $prefix . '--' . $slug;
|
||||
@@ -85,7 +89,7 @@ $lit_json = static fn (array $data): string => wp_json_encode(
|
||||
) . "\n";
|
||||
|
||||
global $wpdb;
|
||||
$lit_types = ['page', 'et_pb_layout', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout'];
|
||||
$lit_types = ['page', 'et_pb_layout', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout', 'lit_fixture_person'];
|
||||
$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')) . ")
|
||||
@@ -113,7 +117,11 @@ foreach ($lit_tb_templates as $templateId) {
|
||||
}
|
||||
|
||||
if ($lit_ids === []) {
|
||||
WP_CLI::error('No "[Fixture] " posts found – create them first (see tests/Fixtures/divi/stage3/README.md).');
|
||||
WP_CLI::error(sprintf(
|
||||
'No "%s" posts found – create them first (see tests/Fixtures/divi/%s/README.md).',
|
||||
$lit_prefix,
|
||||
$lit_stage
|
||||
));
|
||||
}
|
||||
|
||||
if (! is_dir($lit_dir) && ! wp_mkdir_p($lit_dir)) {
|
||||
|
||||
@@ -18,7 +18,7 @@ if (! class_exists(ModuleRegistration::class)) {
|
||||
|
||||
$wanted = [
|
||||
'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field', 'post-content',
|
||||
];
|
||||
$all = ModuleRegistration::get_all_core_modules_metadata();
|
||||
$metadata = [];
|
||||
@@ -40,6 +40,7 @@ foreach ($wanted as $key) {
|
||||
'category' => $module['category'] ?? '',
|
||||
'childrenName' => $module['childrenName'] ?? null,
|
||||
'childModuleName' => $module['childModuleName'] ?? null,
|
||||
'appearance' => $module['appearance'] ?? null,
|
||||
'attributes' => $attributes,
|
||||
], static fn ($value): bool => $value !== null);
|
||||
$defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default');
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Creates or removes the Pods test content type "lit_person" for integration tests.
|
||||
* Creates or removes the Pods test content type "lit_person" for integration tests, or creates the
|
||||
* permanent Etappe 3b fixture pod "lit_fixture_person" with two persons (tests/Fixtures/divi/stage3b).
|
||||
*
|
||||
* Usage (inside DDEV, Pods must be active):
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php fixture
|
||||
*
|
||||
* "delete" only removes "lit_person"; the fixture pod is kept (smokes never touch it).
|
||||
*
|
||||
* No strict_types: wp eval-file runs this through eval().
|
||||
*/
|
||||
@@ -28,6 +32,72 @@ if ($action === 'delete') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'fixture') {
|
||||
// Etappe 3b fixture pod (ruling Q1): its own type, so no smoke deletes the fixture persons.
|
||||
if (! $api->load_pod(['name' => 'lit_fixture_person'])) {
|
||||
$api->save_pod([
|
||||
'name' => 'lit_fixture_person',
|
||||
'label' => '[Fixture] Personen',
|
||||
'label_singular' => '[Fixture] Person',
|
||||
'type' => 'post_type',
|
||||
'storage' => 'meta',
|
||||
'public' => 1,
|
||||
'show_ui' => 1,
|
||||
]);
|
||||
}
|
||||
$fixtureFields = [
|
||||
['name' => 'role', 'label' => 'Funktion', 'type' => 'text'],
|
||||
[
|
||||
'name' => 'level',
|
||||
'label' => 'Stufe',
|
||||
'type' => 'pick',
|
||||
'pick_object' => 'custom-simple',
|
||||
'pick_custom' => "junior|Junior\nsenior|Senior",
|
||||
],
|
||||
[
|
||||
'name' => 'mentor',
|
||||
'label' => 'Mentor',
|
||||
'type' => 'pick',
|
||||
'pick_object' => 'post_type',
|
||||
'pick_val' => 'lit_fixture_person',
|
||||
'pick_format_type' => 'single',
|
||||
],
|
||||
];
|
||||
foreach ($fixtureFields as $field) {
|
||||
if (! $api->load_field(['pod' => 'lit_fixture_person', 'name' => $field['name']])) {
|
||||
$api->save_field(array_merge(['pod' => 'lit_fixture_person'], $field));
|
||||
}
|
||||
}
|
||||
$existing = static function (string $title): int {
|
||||
$found = get_posts([
|
||||
'post_type' => 'lit_fixture_person',
|
||||
'post_status' => 'any',
|
||||
'title' => $title,
|
||||
'fields' => 'ids',
|
||||
'numberposts' => 1,
|
||||
]);
|
||||
|
||||
return (int) ($found[0] ?? 0);
|
||||
};
|
||||
$erika = $existing('[Fixture 3b] Erika Muster') ?: (int) pods('lit_fixture_person')->add([
|
||||
'post_title' => '[Fixture 3b] Erika Muster',
|
||||
'post_content' => '<p>Beitragsinhalt der Fixture-Person Erika Muster.</p>',
|
||||
'post_status' => 'publish',
|
||||
'role' => 'Geschäftsführung',
|
||||
'level' => 'senior',
|
||||
]);
|
||||
$max = $existing('[Fixture 3b] Max Beispiel') ?: (int) pods('lit_fixture_person')->add([
|
||||
'post_title' => '[Fixture 3b] Max Beispiel',
|
||||
'post_content' => '<p>Beitragsinhalt der Fixture-Person Max Beispiel.</p>',
|
||||
'post_status' => 'publish',
|
||||
'role' => 'Entwicklung',
|
||||
'level' => 'junior',
|
||||
'mentor' => $erika,
|
||||
]);
|
||||
echo "persons {$erika} {$max}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $api->load_pod(['name' => 'lit_person'])) {
|
||||
$api->save_pod([
|
||||
'name' => 'lit_person',
|
||||
|
||||
Executable
+543
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end checks of Etappe 3b (Divi Theme Builder, Pods fields, find-similar, make-global, P10, P13) 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/tb-smoke.sh
|
||||
# REQUIRE_PODS=1 makes the Pods section mandatory (else it is skipped with SKIP when Pods is inactive).
|
||||
#
|
||||
# Creates only temporary data of this run (titles "LIT Smoke <run> …"): draft pages, a post, Theme Builder templates
|
||||
# and layouts, library entries, its OWN temporary Pods pod (ruling Q6 – lit_person and lit_fixture_person are never
|
||||
# touched), a role cloned from "editor" (no edit_theme_options, so no Theme Builder) with one user, and it changes the
|
||||
# options lit_mcp_allowed_roles/lit_mcp_exposure for a while. Visible site: the only template this script ever
|
||||
# enables has exactly one condition – a temporary DRAFT page, which visitors cannot open (it is rendered through a
|
||||
# logged-in preview). The EXIT trap removes, by exact ID: the created templates from the live Theme Builder list,
|
||||
# all created posts (with revisions, meta and term relationships), a Theme Builder draft it may have created,
|
||||
# change-log rows, the pod, the user with its app passwords and sessions and the role; it restores every option it
|
||||
# touched (lit_mcp_*, Divi's Theme Builder backup option, rewrite_rules; absent stays absent) and clears Divi's
|
||||
# caches. The last section checks that the Theme Builder list and every template's enabled state equal the state
|
||||
# before the run and that nothing of the run is left. [Fixture]/[Test] content is only read.
|
||||
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"
|
||||
DIGITS=$(printf '%s' "$RUN" | tr -dc '0-9')
|
||||
# Per-run names: cleanup never touches an account, role or pod that existed before this run.
|
||||
EDITOR_LOGIN="lit-tb-editor-$RUN"
|
||||
EDITOR_ROLE="lit_tb_editor_${RUN//-/_}"
|
||||
POD="lit_smk_${DIGITS: -8}"
|
||||
EDITOR_CREATED=0
|
||||
ROLE_CREATED=0
|
||||
POD_CREATED=0
|
||||
POD_POSTS=()
|
||||
CREATED_POSTS=()
|
||||
CREATED_TEMPLATES=()
|
||||
DRAFT_TB=""
|
||||
SESSION_COOKIE=""
|
||||
|
||||
# State before the run (restored by cleanup, compared at the end).
|
||||
option_json() { wp option get "$1" --format=json 2>/dev/null || echo '__none__'; }
|
||||
ORIGINAL_ROLES=$(option_json lit_mcp_allowed_roles)
|
||||
ORIGINAL_EXPOSURE=$(option_json lit_mcp_exposure)
|
||||
ORIGINAL_REWRITE=$(option_json rewrite_rules)
|
||||
TB_ID=$(wp eval 'echo (int) et_theme_builder_get_theme_builder_post_id(true, false);')
|
||||
BACKUP_OPTION="et_tb_templates_backup_$TB_ID"
|
||||
ORIGINAL_BACKUP=$(option_json "$BACKUP_OPTION")
|
||||
# The live Theme Builder list with every template's _et_enabled, exactly as stored.
|
||||
tb_state() {
|
||||
LIT_TB="$TB_ID" wp eval '
|
||||
$tb = (int) getenv("LIT_TB");
|
||||
$rows = get_post_meta($tb, "_et_template");
|
||||
$enabled = [];
|
||||
foreach ($rows as $id) { $enabled[(string) $id] = get_post_meta((int) $id, "_et_enabled", true); }
|
||||
echo wp_json_encode(["list" => $rows, "enabled" => $enabled]);'
|
||||
}
|
||||
TB_BEFORE=$(tb_state)
|
||||
# Divi's front-end feature caches (created by any page view, e.g. this script's renders): absent stays absent.
|
||||
divi_caches() {
|
||||
wp eval 'global $wpdb; echo implode(" ", $wpdb->get_col("SELECT option_name FROM $wpdb->options WHERE option_name LIKE \"\\_et\\_builder\\_%\\_feature\\_cache\" ORDER BY option_name"));'
|
||||
}
|
||||
DIVI_CACHES_BEFORE=" $(divi_caches) "
|
||||
|
||||
restore_option() { # $1 = option, $2 = original JSON or __none__
|
||||
if [ "$2" = "__none__" ]; then
|
||||
wp option delete "$1" >/dev/null 2>&1 || true
|
||||
else
|
||||
wp option update "$1" "$2" --format=json >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Every created post (and those a stopped run could not record: title prefix of this run) – idempotent.
|
||||
cleanup() {
|
||||
local found id name
|
||||
found=$(LIT_PREFIX="$PREFIX " wp eval '
|
||||
global $wpdb;
|
||||
echo implode(" ", $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT ID FROM $wpdb->posts WHERE post_title LIKE %s AND post_type IN (%s, %s, %s, %s, %s, %s, %s)",
|
||||
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%",
|
||||
"page", "post", "et_pb_layout", "et_template", "et_header_layout", "et_body_layout", "et_footer_layout"
|
||||
)));' 2>/dev/null || true)
|
||||
for id in $found; do CREATED_POSTS+=("$id"); done
|
||||
# 1. Off the live list first (nothing of this run may stay visible or listed), then disabled.
|
||||
for id in "${CREATED_TEMPLATES[@]}"; do
|
||||
LIT_ID="$id" LIT_TB="$TB_ID" wp eval '
|
||||
$id = (string) getenv("LIT_ID");
|
||||
delete_post_meta((int) getenv("LIT_TB"), "_et_template", $id);
|
||||
update_post_meta((int) $id, "_et_enabled", "0");' >/dev/null 2>&1 || true
|
||||
done
|
||||
if [ -n "$DRAFT_TB" ]; then wp post delete "$DRAFT_TB" --force >/dev/null 2>&1 || true; DRAFT_TB=""; fi
|
||||
# 2. 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[*]} ${CREATED_TEMPLATES[*]}" 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]); }
|
||||
$types = ["page", "post", "et_pb_layout", "et_template", "et_header_layout", "et_body_layout", "et_footer_layout"];
|
||||
foreach (array_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) {
|
||||
foreach ($types as $type) { $wpdb->delete($table, ["object_type" => $type, "object_id" => $id]); }
|
||||
}' >/dev/null 2>&1 || true
|
||||
# 3. Posts with revisions and meta; term relationships also for types a taxonomy is not registered for.
|
||||
LIT_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]}" wp eval '
|
||||
global $wpdb;
|
||||
foreach (array_unique(array_filter(array_map("intval", explode(" ", (string) getenv("LIT_IDS"))))) as $id) {
|
||||
if (get_post($id)) { wp_delete_post($id, true); }
|
||||
$wpdb->delete($wpdb->term_relationships, ["object_id" => $id]);
|
||||
}' >/dev/null 2>&1 || true
|
||||
CREATED_POSTS=()
|
||||
CREATED_TEMPLATES=()
|
||||
# 4. The temporary pod (definition, fields, content) and anything of it left by ID.
|
||||
if [ "$POD_CREATED" = 1 ]; then
|
||||
LIT_POD="$POD" wp eval '
|
||||
if (function_exists("pods_api") && pods_api()->load_pod(["name" => getenv("LIT_POD")])) {
|
||||
pods_api()->delete_pod(["name" => getenv("LIT_POD")], false, true);
|
||||
}' >/dev/null 2>&1 || true
|
||||
LIT_POD="$POD" LIT_IDS="${POD_POSTS[*]}" wp eval '
|
||||
global $wpdb;
|
||||
$ids = array_merge(array_map("intval", array_filter(explode(" ", (string) getenv("LIT_IDS")))),
|
||||
array_map("intval", $wpdb->get_col($wpdb->prepare("SELECT ID FROM $wpdb->posts WHERE post_type = %s", getenv("LIT_POD")))));
|
||||
foreach (array_unique($ids) as $id) { if ($id > 0 && get_post($id)) { wp_delete_post($id, true); } }' >/dev/null 2>&1 || true
|
||||
POD_CREATED=0
|
||||
fi
|
||||
# 5. Accounts, options, caches.
|
||||
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
|
||||
EDITOR_CREATED=0
|
||||
fi
|
||||
if [ "$ROLE_CREATED" = 1 ]; then
|
||||
wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true
|
||||
ROLE_CREATED=0
|
||||
fi
|
||||
restore_option lit_mcp_allowed_roles "$ORIGINAL_ROLES"
|
||||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||||
restore_option "$BACKUP_OPTION" "$ORIGINAL_BACKUP"
|
||||
restore_option rewrite_rules "$ORIGINAL_REWRITE"
|
||||
for name in $(divi_caches 2>/dev/null || true); do
|
||||
if [[ "$DIVI_CACHES_BEFORE" != *" $name "* ]]; then wp option delete "$name" >/dev/null 2>&1 || true; fi
|
||||
done
|
||||
wp eval 'if (function_exists("et_theme_builder_clear_wp_cache")) { et_theme_builder_clear_wp_cache("all"); }
|
||||
if (class_exists("ET_Core_PageResource")) { ET_Core_PageResource::remove_static_resources("all", "all", true); }' \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||||
|
||||
has() { grep -qF -- "$2" <<<"$1"; } # $1 = haystack, $2 = needle (no pipe: pipefail + grep -q would race)
|
||||
|
||||
create_post() { # $1 = post type, $2 = title suffix, $3 = status, $4 = markup (optional) -> ID
|
||||
LIT_TYPE="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="$3" LIT_CONTENT="${4:-}" wp --user="$ADMIN_LOGIN" eval '
|
||||
$id = wp_insert_post(["post_type" => getenv("LIT_TYPE"), "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;'
|
||||
}
|
||||
|
||||
content_of() { wp eval "echo (string) get_post($1)->post_content;"; }
|
||||
content_hash() { wp eval "echo hash('sha256', (string) get_post($1)->post_content);"; }
|
||||
rev_of() { tool lit-divi-outline "{\"post_id\":$1}" | jq -r '.tree_rev'; }
|
||||
enabled_of() { wp post meta get "$1" _et_enabled; }
|
||||
listed() { tb_state | jq -e --arg t "$1" '.list | index($t) != null' >/dev/null; } # on the live Theme Builder list
|
||||
tb_list() { # $1 = arguments JSON (default {})
|
||||
local args='{}'
|
||||
if [ $# -gt 0 ]; then args=$1; fi
|
||||
ability lit/tb-list "$args"
|
||||
}
|
||||
template_rev() { tb_list "{\"template_id\":$1}" | jq -r '.templates[0].template_rev // .detached[0].template_rev'; }
|
||||
activate() { # $1 = template ID, $2 = enabled (true|false), $3 = confirm (true|false)
|
||||
ability lit/tb-activate "$(jq -cn --argjson id "$1" --arg rev "$(template_rev "$1")" --argjson e "$2" --argjson c "$3" \
|
||||
'{template_id: $id, template_rev: $rev, enabled: $e} + (if $c then {confirm: true} else {} end)')"
|
||||
}
|
||||
details() { echo "$1" | jq -r '.__error' | jq -c '.'; } # the details of a tool error (confirm_required etc.)
|
||||
# Front-end HTML: $1 = post ID; $2 = "preview" renders it for the logged-in temporary editor (drafts), else anonymous.
|
||||
render_of() {
|
||||
local cookie=()
|
||||
if [ "${2:-}" = preview ]; then cookie=(-H "Cookie: $SESSION_COOKIE"); fi
|
||||
curl -s --max-time 30 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "${cookie[@]}" "http://localhost/?page_id=$1"
|
||||
}
|
||||
render_home() { curl -s --max-time 30 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "http://localhost/"; }
|
||||
|
||||
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
|
||||
[ "$TB_ID" -gt 0 ] || fail "setup: no live Theme Builder post (save the Theme Builder once)"
|
||||
[ "$(wp post list --post_type=et_theme_builder --post_status=auto-draft --format=count)" = "0" ] \
|
||||
|| fail "setup: the Theme Builder has unsaved changes – save or discard them first"
|
||||
echo "$TB_BEFORE" | jq -e '[.enabled[]] | all(. != "1")' >/dev/null \
|
||||
|| fail "setup: a Theme Builder template is enabled – this local site must show no active template before the run"
|
||||
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
|
||||
|
||||
# Temporary editor (per-run role cloned from editor: no edit_theme_options, Divi's "Theme Builder" option off).
|
||||
wp role create "$EDITOR_ROLE" "LIT Smoke Theme Builder" --clone=editor >/dev/null
|
||||
ROLE_CREATED=1
|
||||
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" wp eval 'echo user_can(get_user_by("login", getenv("LIT_LOGIN")), "edit_theme_options") ? "yes" : "no";')" = "no" ] \
|
||||
|| fail "setup: the temporary editor must lack edit_theme_options"
|
||||
# Its logged-in cookie previews the temporary draft page (session stored on the temporary user only).
|
||||
SESSION_COOKIE=$(LIT_LOGIN="$EDITOR_LOGIN" wp eval '
|
||||
$u = get_user_by("login", getenv("LIT_LOGIN"));
|
||||
$expires = time() + 1800;
|
||||
$token = WP_Session_Tokens::get_instance($u->ID)->create($expires);
|
||||
echo LOGGED_IN_COOKIE . "=" . wp_generate_auth_cookie($u->ID, $expires, "logged_in", $token);')
|
||||
|
||||
connect "$ADMIN_LOGIN"
|
||||
|
||||
# 1. Exposure: still 22 direct lit tools; the six new abilities are indirect and reachable; no TB compat problem
|
||||
LIST=$(rpc tools/list '{}')
|
||||
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)' >/dev/null \
|
||||
|| fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')"
|
||||
for a in lit/tb-list lit/tb-create-template lit/tb-assign lit/tb-activate lit/library-find-similar lit/library-make-global; do
|
||||
if echo "$LIST" | jq -e --arg t "lit-${a#lit/}" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$a must be indirect"; fi
|
||||
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"
|
||||
done
|
||||
STATUS=$(tool lit-site-status '{}')
|
||||
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .active == true and .problems == []' >/dev/null \
|
||||
|| fail "Divi compatibility findings: $STATUS"
|
||||
pass "exposure: 22 direct tools, the six new abilities indirect and reachable; no Divi/Theme Builder compatibility problem"
|
||||
|
||||
# 2. Create a template: disabled, on the live list, body layout by Divi with the plugin's content, invisible
|
||||
PAGE=$(create_post page "Entwurf" draft '')
|
||||
CREATED_POSTS+=("$PAGE")
|
||||
[ "$(wp post get "$PAGE" --field=post_status)" = "draft" ] || fail "setup: the target page must be a draft"
|
||||
MARKER="LIT-Smoke-Marker-$RUN"
|
||||
BASE_PREVIEW=$(render_of "$PAGE" preview)
|
||||
has "$BASE_PREVIEW" "$PREFIX Entwurf" || fail "setup: the preview does not show the draft page: $(head -c 300 <<<"$BASE_PREVIEW")"
|
||||
CREATED=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Vorlage" --arg m "$MARKER" --argjson p "$PAGE" '{
|
||||
title: $t,
|
||||
header: {source: "global"},
|
||||
body: {source: "new", nodes: [{type: "section", modules: [{type: "heading", text: "{{post:title}}"}, {type: "text", content: $m}]}]},
|
||||
footer: {source: "hidden"},
|
||||
use_on: ["singular:post_type:page:id:\($p)"],
|
||||
exclude_from: ["search"]}')")
|
||||
TPL=$(echo "$CREATED" | jq -r '.template_id // empty')
|
||||
[ -n "$TPL" ] || fail "tb-create-template: $CREATED"
|
||||
CREATED_TEMPLATES+=("$TPL")
|
||||
BODY=$(echo "$CREATED" | jq -r '.layouts.body.id')
|
||||
CREATED_POSTS+=("$BODY")
|
||||
echo "$CREATED" | jq -e '.enabled == false and .layouts.footer.enabled == false and .layouts.header.source == "global"
|
||||
and .layouts.body.source == "new"' >/dev/null || fail "tb-create-template result: $CREATED"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "the new template is enabled"
|
||||
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "conditions: $(wp post meta get "$TPL" _et_use_on)"
|
||||
listed "$TPL" || fail "template $TPL is not on the live Theme Builder list"
|
||||
[ "$(wp post get "$BODY" --field=post_type)" = "et_body_layout" ] || fail "body layout type"
|
||||
[ "$(wp post meta get "$BODY" _et_pb_use_divi_5)" = "on" ] || fail "body layout without the Divi 5 flag"
|
||||
BODY_CONTENT=$(content_of "$BODY")
|
||||
[[ "$BODY_CONTENT" == "<!-- wp:divi/placeholder -->"* ]] || fail "body layout content: $BODY_CONTENT"
|
||||
has "$BODY_CONTENT" "$MARKER" || fail "body layout lacks the marker"
|
||||
tb_list "{\"template_id\":$TPL}" | jq -e '.templates[0] | .enabled == false and .live == false and .attached == true' >/dev/null \
|
||||
|| fail "tb-list does not show the new template disabled and attached"
|
||||
if has "$(render_of "$PAGE" preview)" "$MARKER"; then fail "a disabled template is rendered"; fi
|
||||
pass "tb-create-template: disabled, on the live list, body layout by Divi with the plugin's content, not rendered"
|
||||
|
||||
# 3. Divi tools on the body of a disabled template save at once; post-content is allowed in the body
|
||||
UPD=$(tool lit-divi-insert "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" \
|
||||
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "post-content"}]}')")
|
||||
echo "$UPD" | jq -e '.ok == true and .templates == []' >/dev/null || fail "insert into the body of a disabled template: $UPD"
|
||||
has "$(content_of "$BODY")" "wp:divi/post-content" || fail "post-content not stored in the body"
|
||||
pass "Divi tools write the body of a disabled template without confirm; post-content allowed in the body"
|
||||
|
||||
# 4. tb-assign while disabled (Q3): an omitted exclude_from stays, conditions still only the draft page
|
||||
ASSIGN=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"]}')")
|
||||
echo "$ASSIGN" | jq -e '.ok == true and .live == false and .previous.exclude_from == ["search"]' >/dev/null || fail "tb-assign: $ASSIGN"
|
||||
[ "$(wp post meta get "$TPL" _et_exclude_from)" = "search" ] || fail "an omitted exclude_from changed the exceptions"
|
||||
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "use_on after tb-assign"
|
||||
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: "abcdef012345", use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "rev_mismatch" ] \
|
||||
|| fail "tb-assign with a stale template_rev"
|
||||
pass "tb-assign on a disabled template: saved at once, omitted exclude_from kept (Q3), stale template_rev refused"
|
||||
|
||||
# 5. Activation: dry run, then confirm; the draft page shows the template, visitors see nothing new
|
||||
DRY=$(activate "$TPL" true false)
|
||||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "activation without confirm: $DRY"
|
||||
details "$DRY" | jq -e --arg p "$PAGE" '.use_on | length == 1 and (.[0] | contains($p))' >/dev/null || fail "activation dry run: $DRY"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "the dry run enabled the template"
|
||||
ON=$(activate "$TPL" true true)
|
||||
echo "$ON" | jq -e '.enabled == true and .live == true' >/dev/null || fail "activation: $ON"
|
||||
[ "$(enabled_of "$TPL")" = "1" ] || fail "template not enabled"
|
||||
PREVIEW=$(render_of "$PAGE" preview)
|
||||
has "$PREVIEW" "$MARKER" || fail "the active template is not rendered for its draft page"
|
||||
if has "$(render_of "$PAGE")" "$MARKER"; then fail "a visitor sees the draft page's template"; fi
|
||||
if has "$(render_home)" "$MARKER"; then fail "the template shows on the home page"; fi
|
||||
pass "tb-activate: dry run first, then enabled; only the draft page (preview) shows it, visitors see nothing"
|
||||
|
||||
# 6. Live template: layout changes and condition changes are a dry run first (Q9), Q3 on the live template
|
||||
HASH=$(content_hash "$BODY")
|
||||
CHANGE=$(jq -cn --arg m "$MARKER-geändert" '[{id: "s0.r0.c0.m1", set: {content: $m}}]')
|
||||
LIVE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')")
|
||||
[ "$(error_code "$LIVE")" = "confirm_required" ] || fail "visible layout changed without confirm: $LIVE"
|
||||
details "$LIVE" | jq -e --argjson t "$TPL" '.templates | map(.id) | index($t) != null' >/dev/null || fail "dry run lacks the template: $LIVE"
|
||||
[ "$(content_hash "$BODY")" = "$HASH" ] || fail "the dry run wrote the layout"
|
||||
DEL=$(tool lit-divi-delete "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" '{post_id: $id, tree_rev: $rev, ids: ["s0.r0.c0.m0"]}')")
|
||||
[ "$(error_code "$DEL")" = "confirm_required" ] || fail "visible layout: delete without confirm: $DEL"
|
||||
[ "$(content_hash "$BODY")" = "$HASH" ] || fail "the delete dry run wrote the layout"
|
||||
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --argjson c "$CHANGE" \
|
||||
'{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')")
|
||||
echo "$DONE" | jq -e --argjson t "$TPL" '.ok == true and (.templates | map(.id) | index($t) != null)
|
||||
and (.restore_url | test("/wp-admin/revision\\.php\\?revision=[0-9]+$"))' >/dev/null \
|
||||
|| fail "confirmed layout change (templates, restore_url): $DONE"
|
||||
has "$(render_of "$PAGE" preview)" "$MARKER-geändert" || fail "the changed layout is not rendered"
|
||||
REASSIGN=$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], exclude_from: []}')
|
||||
ADRY=$(ability lit/tb-assign "$REASSIGN")
|
||||
[ "$(error_code "$ADRY")" = "confirm_required" ] || fail "conditions of an active template changed without confirm: $ADRY"
|
||||
details "$ADRY" | jq -e '(.before.exclude_from | length) == 1 and .after.exclude_from == [] and (.after.use_on | length) == 1' >/dev/null \
|
||||
|| fail "tb-assign dry run before/after: $ADRY"
|
||||
[ "$(wp post meta get "$TPL" _et_exclude_from)" = "search" ] || fail "the dry run changed the exceptions"
|
||||
ACONF=$(ability lit/tb-assign "$(echo "$REASSIGN" | jq -c '. + {confirm: true}')")
|
||||
echo "$ACONF" | jq -e '.ok == true and .live == true and .previous.exclude_from == ["search"]' >/dev/null || fail "confirmed tb-assign: $ACONF"
|
||||
[ -z "$(wp post meta get "$TPL" _et_exclude_from 2>/dev/null || true)" ] || fail "exclude_from [] did not clear the exceptions"
|
||||
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "use_on of the live template changed"
|
||||
pass "live template: layout update/delete and conditions only after a dry run; confirmed change rendered; [] clears exceptions"
|
||||
|
||||
# 7. Theme Builder draft: blocks create/assign/enable, the off-switch still works (Q20); default template refused
|
||||
DEFAULT=$(tb_list | jq -r '[.templates[] | select(.default)][0].id // empty')
|
||||
if [ -n "$DEFAULT" ]; then
|
||||
DEF_ENABLED=$(tb_list "{\"template_id\":$DEFAULT}" | jq -r '.templates[0].enabled')
|
||||
[ "$(error_code "$(activate "$DEFAULT" "$([ "$DEF_ENABLED" = true ] && echo false || echo true)" false)")" = "unsupported" ] \
|
||||
|| fail "the default template is not refused"
|
||||
fi
|
||||
TPL_REV=$(template_rev "$TPL")
|
||||
DRAFT_TB=$(wp eval 'echo (int) wp_insert_post(["post_type" => "et_theme_builder", "post_status" => "auto-draft", "post_title" => "Theme Builder"]);')
|
||||
[ "$DRAFT_TB" -gt 0 ] || fail "setup: Theme Builder draft"
|
||||
[ "$(error_code "$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Gesperrt" '{title: $t}')")")" = "unsupported" ] \
|
||||
|| fail "tb-create-template despite a Theme Builder draft"
|
||||
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], confirm: true}')")")" = "unsupported" ] \
|
||||
|| fail "tb-assign despite a Theme Builder draft"
|
||||
OFF_DRY=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" '{template_id: $id, template_rev: $rev, enabled: false}')")
|
||||
[ "$(error_code "$OFF_DRY")" = "confirm_required" ] || fail "deactivation without confirm: $OFF_DRY"
|
||||
[ "$(enabled_of "$TPL")" = "1" ] || fail "the deactivation dry run switched"
|
||||
OFF=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" '{template_id: $id, template_rev: $rev, enabled: false, confirm: true}')")
|
||||
echo "$OFF" | jq -e '.enabled == false and .live == false and (.warnings | map(contains("Entwurf")) | any)' >/dev/null \
|
||||
|| fail "off-switch with a Theme Builder draft: $OFF"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "template still enabled"
|
||||
[ "$(error_code "$(activate "$TPL" true true)")" = "unsupported" ] || fail "tb-activate (on) despite a Theme Builder draft"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "enabled despite a Theme Builder draft"
|
||||
wp post delete "$DRAFT_TB" --force >/dev/null
|
||||
DRAFT_TB=""
|
||||
if has "$(render_of "$PAGE" preview)" "$MARKER"; then fail "a deactivated template is still rendered"; fi
|
||||
pass "Theme Builder draft blocks create, assign and enable; the off-switch works with a warning; default template refused"
|
||||
|
||||
# 8. Detached template (a stale Theme Builder save removed it): listed, refused, reattached disabled on request
|
||||
LIT_TB="$TB_ID" LIT_ID="$TPL" wp eval '
|
||||
delete_post_meta((int) getenv("LIT_TB"), "_et_template", (string) getenv("LIT_ID"));
|
||||
update_post_meta((int) getenv("LIT_ID"), "_et_theme_builder_marked_as_unused", "2026-09-27 00:00:00");' >/dev/null
|
||||
if listed "$TPL"; then fail "setup: template still listed"; fi
|
||||
tb_list | jq -e --argjson t "$TPL" '(.detached | map(.id) | index($t) != null) and (.templates | map(.id) | index($t) == null)' >/dev/null \
|
||||
|| fail "detached template not listed under detached"
|
||||
[ "$(error_code "$(activate "$TPL" true true)")" = "validation_failed" ] || fail "a detached template was activated"
|
||||
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "validation_failed" ] \
|
||||
|| fail "tb-assign on a detached template without reattach"
|
||||
RE=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], reattach: true}')")
|
||||
echo "$RE" | jq -e '.ok == true and .live == false' >/dev/null || fail "reattach: $RE"
|
||||
listed "$TPL" || fail "not reattached"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "reattached enabled"
|
||||
[ -z "$(wp post meta get "$TPL" _et_theme_builder_marked_as_unused 2>/dev/null || true)" ] || fail "unused mark kept"
|
||||
pass "stale-editor detach: listed as detached, activate/assign refused, reattach puts it back disabled and unmarked"
|
||||
|
||||
# 9. Rights: an editor without the Theme Builder; group "nur lesen" and "deaktiviert" gate the Divi tools
|
||||
connect "$EDITOR_LOGIN"
|
||||
[ "$(error_code "$(ability lit/tb-list '{}')")" = "forbidden" ] || fail "editor lists the Theme Builder"
|
||||
[ "$(error_code "$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Redakteur" '{title: $t}')")")" = "forbidden" ] \
|
||||
|| fail "editor creates a template"
|
||||
# Review M-9: without the Theme Builder rights the refusal is forbidden whatever the ID (like tb-list).
|
||||
[ "$(error_code "$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" '{template_id: $id, template_rev: "abcdef012345", enabled: true, confirm: true}')")")" = "forbidden" ] \
|
||||
|| fail "editor reaches the template"
|
||||
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --argjson p "$PAGE" \
|
||||
'{template_id: $id, template_rev: "abcdef012345", use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "forbidden" ] \
|
||||
|| fail "editor reaches the template's conditions"
|
||||
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "editor reads a Theme Builder layout"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "editor refusal changed the template"
|
||||
connect "$ADMIN_LOGIN"
|
||||
wp option update lit_mcp_exposure '{"@readonly:divi-theme-builder":true}' --format=json >/dev/null
|
||||
connect "$ADMIN_LOGIN"
|
||||
[ "$(error_code "$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" \
|
||||
'{post_id: $id, tree_rev: $rev, changes: [{id: "s0.r0.c0.m1", label: "Nur lesen"}]}')")")" = "forbidden" ] \
|
||||
|| fail "group read-only: Theme Builder layout written"
|
||||
[ -n "$(template_rev "$TPL")" ] || fail "group read-only: tb-list must stay readable"
|
||||
RO=$(activate "$TPL" true true)
|
||||
has "$RO" "Ability 'lit/tb-activate' not found" || fail "group read-only: tb-activate still executable: $RO"
|
||||
[ "$(enabled_of "$TPL")" = "0" ] || fail "group read-only: template enabled"
|
||||
wp option update lit_mcp_exposure '{"@group:divi-theme-builder":"disabled"}' --format=json >/dev/null
|
||||
connect "$ADMIN_LOGIN"
|
||||
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "group disabled: Theme Builder layout readable"
|
||||
GONE=$(ability lit/tb-list '{}')
|
||||
echo "$GONE" | jq -e 'has("__error")' >/dev/null && has "$GONE" 'not found' || fail "group disabled: tb-list still executable: $GONE"
|
||||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||||
connect "$ADMIN_LOGIN"
|
||||
pass "editor without the Theme Builder: forbidden/not_found everywhere; group read-only and disabled gate the Divi tools"
|
||||
|
||||
# 10. Pods fields in a Theme Builder body – with a temporary pod of this run (ruling Q6)
|
||||
if wp plugin is-active pods >/dev/null 2>&1; then
|
||||
if [ "$(LIT_POD="$POD" wp eval 'echo pods_api()->load_pod(["name" => getenv("LIT_POD")]) ? "yes" : "no";')" != "no" ] \
|
||||
|| [ "$POD" = lit_person ] || [ "$POD" = lit_fixture_person ]; then
|
||||
fail "pod $POD exists already"
|
||||
fi
|
||||
POD_CREATED=1
|
||||
POD_IDS=$(LIT_POD="$POD" LIT_LABEL="$PREFIX Personen" wp eval '
|
||||
$api = pods_api();
|
||||
$pod = getenv("LIT_POD");
|
||||
$ids = [(int) $api->save_pod(["name" => $pod, "label" => getenv("LIT_LABEL"), "label_singular" => getenv("LIT_LABEL"),
|
||||
"type" => "post_type", "storage" => "meta", "public" => 1, "show_ui" => 1])];
|
||||
foreach ([
|
||||
["name" => "role", "label" => "Funktion", "type" => "text"],
|
||||
["name" => "level", "label" => "Stufe", "type" => "pick", "pick_object" => "custom-simple", "pick_custom" => "junior|Junior\nsenior|Senior"],
|
||||
["name" => "mentor", "label" => "Mentor", "type" => "pick", "pick_object" => "post_type", "pick_val" => $pod, "pick_format_type" => "single"],
|
||||
] as $field) {
|
||||
$ids[] = (int) $api->save_field(array_merge(["pod" => $pod], $field));
|
||||
}
|
||||
echo implode(" ", $ids);')
|
||||
for id in $POD_IDS; do POD_POSTS+=("$id"); done
|
||||
[ "$(echo "$POD_IDS" | wc -w)" = 4 ] || fail "setup: temporary pod $POD: $POD_IDS"
|
||||
CONDS=$(tb_list '{"conditions":true}')
|
||||
echo "$CONDS" | jq -e --arg c "singular:post_type:$POD:all" '.conditions | map(.id) | index($c) != null' >/dev/null \
|
||||
|| fail "Divi offers no condition for the temporary pod"
|
||||
PODS=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Person-Vorlage" --arg pod "$POD" '{
|
||||
title: $t, header: {source: "default"}, footer: {source: "default"},
|
||||
body: {source: "new", nodes: [{type: "section", modules: [
|
||||
{type: "heading", text: "{{post:title}}"}, {type: "text", content: "{{field:role}}"},
|
||||
{type: "text", content: "{{field:level}}"}, {type: "text", content: "{{field:mentor}}"}]}]},
|
||||
use_on: ["singular:post_type:\($pod):all"]}')")
|
||||
PTPL=$(echo "$PODS" | jq -r '.template_id // empty')
|
||||
[ -n "$PTPL" ] || fail "Pods template: $PODS"
|
||||
CREATED_TEMPLATES+=("$PTPL")
|
||||
CREATED_POSTS+=("$(echo "$PODS" | jq -r '.layouts.body.id')")
|
||||
[ "$(enabled_of "$PTPL")" = "0" ] || fail "the Pods template is enabled"
|
||||
echo "$PODS" | jq -e '[.warnings[] | select(contains("„mentor“"))] | length == 1' >/dev/null || fail "no warning for mentor: $PODS"
|
||||
echo "$PODS" | jq -e '[.warnings[] | select(contains("„level“"))] | length == 1' >/dev/null || fail "no note for level: $PODS"
|
||||
echo "$PODS" | jq -e '[.warnings[] | select(contains("„role“"))] | length == 0' >/dev/null || fail "role wrongly warned: $PODS"
|
||||
PBODY=$(echo "$PODS" | jq -r '.layouts.body.id')
|
||||
PUPD=$(tool lit-divi-insert "$(jq -cn --argjson id "$PBODY" --arg rev "$(rev_of "$PBODY")" \
|
||||
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "text", content: "{{field:mentor}}"}]}')")
|
||||
echo "$PUPD" | jq -e '.ok == true and ([.warnings[] | select(contains("„mentor“"))] | length >= 1)' >/dev/null \
|
||||
|| fail "Divi tool on the Pods body without the mentor warning: $PUPD"
|
||||
pass "Pods (temporary pod $POD): condition offered; relation field warned, choice field noted, text field clean; template disabled"
|
||||
elif [ "${REQUIRE_PODS:-0}" = 1 ]; then
|
||||
fail "Pods is not active (REQUIRE_PODS=1)"
|
||||
else
|
||||
echo "SKIP - Pods section (Pods inactive)"
|
||||
fi
|
||||
|
||||
# 11. find-similar and make-global (mode 1, mode 2: dry run, confirm, identical only)
|
||||
SECTION=$(tr -d '\r' < "$STAGE3/library--library-abschnitt.html")
|
||||
VARIANT=${SECTION/Library-Blurb\"/Library-Blurb anders\"}
|
||||
[ "$VARIANT" != "$SECTION" ] || fail "setup: variant equals the section"
|
||||
A=$(create_post page "Ähnlich A" draft "$SECTION")
|
||||
B=$(create_post page "Ähnlich B" draft "$SECTION")
|
||||
C=$(create_post page "Ähnlich C" draft "$VARIANT")
|
||||
CREATED_POSTS+=("$A" "$B" "$C")
|
||||
SIM=$(ability lit/library-find-similar "$(jq -cn --argjson a "$A" '{post_id: $a, id: "s0", post_type: "page", limit: 200}')")
|
||||
echo "$SIM" | jq -e --argjson b "$B" --argjson c "$C" '
|
||||
([.matches[] | select(.post_id == $b and .id == "s0" and .match == "identical")] | length == 1) and
|
||||
([.matches[] | select(.post_id == $c and .id == "s0" and .match == "structure")] | length == 1)' >/dev/null \
|
||||
|| fail "find-similar: $SIM"
|
||||
MG=$(ability lit/library-make-global "$(jq -cn --argjson a "$A" --arg rev "$(rev_of "$A")" --arg t "$PREFIX Global" \
|
||||
'{post_id: $a, tree_rev: $rev, id: "s0", title: $t}')")
|
||||
LIB=$(echo "$MG" | jq -r '.library_id // empty')
|
||||
[ -n "$LIB" ] || fail "make-global: $MG"
|
||||
CREATED_POSTS+=("$LIB")
|
||||
[ "$(content_of "$LIB")" = "$SECTION" ] || fail "global entry bytes differ from the section"
|
||||
[ "$(wp eval "echo implode(',', wp_get_object_terms($LIB, 'scope', ['fields' => 'slugs']));")" = "global" ] || fail "entry is not global"
|
||||
has "$(content_of "$A")" "\"globalModule\":\"$LIB\",\"blockName\":\"divi/section\"" || fail "page A holds no instance"
|
||||
BHASH=$(content_hash "$B")
|
||||
CHASH=$(content_hash "$C")
|
||||
BREVS=$(wp eval "echo count(wp_get_post_revisions($B));")
|
||||
TARGETS=$(jq -cn --argjson lib "$LIB" --argjson b "$B" --arg rev "$(rev_of "$B")" '{library_id: $lib, targets: [{post_id: $b, tree_rev: $rev, ids: ["s0"]}]}')
|
||||
MDRY=$(ability lit/library-make-global "$TARGETS")
|
||||
[ "$(error_code "$MDRY")" = "confirm_required" ] || fail "replace without dry run: $MDRY"
|
||||
details "$MDRY" | jq -e --argjson b "$B" '.affected | map(.post_id) == [$b]' >/dev/null || fail "make-global dry run: $MDRY"
|
||||
[ "$(content_hash "$B")" = "$BHASH" ] || fail "the dry run changed page B"
|
||||
BAD=$(ability lit/library-make-global "$(jq -cn --argjson lib "$LIB" --argjson c "$C" --arg rev "$(rev_of "$C")" \
|
||||
'{library_id: $lib, confirm: true, targets: [{post_id: $c, tree_rev: $rev, ids: ["s0"]}]}')")
|
||||
[ "$(error_code "$BAD")" = "validation_failed" ] || fail "a section that is not identical was replaced: $BAD"
|
||||
[ "$(content_hash "$C")" = "$CHASH" ] || fail "the refused target C changed"
|
||||
MCONF=$(ability lit/library-make-global "$(echo "$TARGETS" | jq -c '. + {confirm: true}')")
|
||||
echo "$MCONF" | jq -e --argjson b "$B" '.pages[0].post_id == $b and .pages[0].revision_id > 0' >/dev/null || fail "replace occurrences: $MCONF"
|
||||
has "$(content_of "$B")" "\"globalModule\":\"$LIB\"" || fail "page B holds no instance"
|
||||
[ "$(wp eval "echo count(wp_get_post_revisions($B));")" -gt "$BREVS" ] || fail "replacing made no revision of page B"
|
||||
[ "$(content_hash "$C")" = "$CHASH" ] || fail "page C changed"
|
||||
pass "find-similar: identical and structure; make-global mode 1 (entry + instance), mode 2 dry run, confirm, identical only"
|
||||
|
||||
# 12. P13: with the library group disabled no library ID is named; P10: post_link_url_page refuses a post
|
||||
wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null
|
||||
connect "$ADMIN_LOGIN"
|
||||
if has "$(tool lit-divi-outline "{\"post_id\":$A}")" "$LIB"; then fail "outline names library ID $LIB with the library hidden"; fi
|
||||
if has "$(tool lit-divi-node-get "{\"post_id\":$A,\"id\":\"g0\"}")" "$LIB"; then fail "node-get names library ID $LIB"; fi
|
||||
if has "$(tool lit-page-render-summary "{\"post_id\":$A}")" "$LIB"; then fail "render summary names library ID $LIB"; fi
|
||||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||||
connect "$ADMIN_LOGIN"
|
||||
has "$(tool lit-divi-outline "{\"post_id\":$A}")" "$LIB" || fail "control: outline does not name $LIB with the library visible"
|
||||
POST=$(create_post post "Beitrag" draft '')
|
||||
CREATED_POSTS+=("$POST")
|
||||
LINK="\$variable({\"type\":\"content\",\"value\":{\"name\":\"post_link_url_page\",\"settings\":{\"post_id\":\"$POST\"}}})\$"
|
||||
PHASH=$(content_hash "$PAGE")
|
||||
P10=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE" --arg rev "$(rev_of "$PAGE")" --arg l "$LINK" \
|
||||
'{post_id: $id, tree_rev: $rev, nodes: [{type: "section", modules: [{type: "button", text: "Mehr", url: $l}]}]}')")
|
||||
[ "$(error_code "$P10")" = "validation_failed" ] || fail "post_link_url_page accepted a post: $P10"
|
||||
[ "$(content_hash "$PAGE")" = "$PHASH" ] || fail "the refused link changed the page"
|
||||
pass "P13: no library ID without library access; P10: post_link_url_page refuses a post as target"
|
||||
|
||||
# 13. Cleanup now and check it: Theme Builder list and enabled states as before, nothing of the run left
|
||||
if has "$(render_home)" "$MARKER"; then fail "the home page shows the marker"; fi
|
||||
KEEP_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]} ${POD_POSTS[*]}"
|
||||
cleanup
|
||||
[ " $(divi_caches) " = "$DIVI_CACHES_BEFORE" ] || fail "Divi feature cache options differ: before$DIVI_CACHES_BEFORE, after $(divi_caches)"
|
||||
[ "$(tb_state)" = "$TB_BEFORE" ] || fail "Theme Builder list/enabled states differ: before $TB_BEFORE, after $(tb_state)"
|
||||
tb_state | jq -e '[.enabled[]] | all(. != "1")' >/dev/null || fail "a Theme Builder template is enabled after the run: $(tb_state)"
|
||||
[ "$(option_json "$BACKUP_OPTION")" = "$ORIGINAL_BACKUP" ] || fail "Theme Builder backup option not restored"
|
||||
[ "$(option_json lit_mcp_exposure)" = "$ORIGINAL_EXPOSURE" ] || fail "lit_mcp_exposure not restored"
|
||||
[ "$(option_json lit_mcp_allowed_roles)" = "$ORIGINAL_ROLES" ] || fail "lit_mcp_allowed_roles not restored"
|
||||
[ "$(option_json rewrite_rules)" = "$ORIGINAL_REWRITE" ] || fail "rewrite_rules not restored"
|
||||
LEFT=$(LIT_IDS="$KEEP_IDS" LIT_PREFIX="$PREFIX" LIT_POD="$POD" LIT_LOGIN="$EDITOR_LOGIN" LIT_ROLE="$EDITOR_ROLE" wp eval '
|
||||
global $wpdb;
|
||||
$ids = array_values(array_unique(array_filter(array_map("intval", explode(" ", (string) getenv("LIT_IDS"))))));
|
||||
$in = $ids === [] ? "0" : implode(",", $ids);
|
||||
$left = [];
|
||||
$left["posts"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->posts WHERE ID IN ($in) OR post_parent IN ($in)");
|
||||
$left["meta"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->postmeta WHERE post_id IN ($in)");
|
||||
$left["terms"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->term_relationships WHERE object_id IN ($in)");
|
||||
$left["log"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log WHERE object_id IN (\"" . implode("\",\"", $ids ?: [0]) . "\")");
|
||||
$left["titles"] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE post_title LIKE %s",
|
||||
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%"));
|
||||
$left["pod"] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = %s OR (post_type IN (\"_pods_pod\", \"_pods_field\") AND post_name = %s)",
|
||||
getenv("LIT_POD"), getenv("LIT_POD")));
|
||||
$left["user"] = get_user_by("login", getenv("LIT_LOGIN")) ? 1 : 0;
|
||||
$left["role"] = wp_roles()->is_role(getenv("LIT_ROLE")) ? 1 : 0;
|
||||
echo wp_json_encode(array_filter($left));')
|
||||
[ "$LEFT" = "[]" ] || fail "left over after cleanup: $LEFT"
|
||||
pass "cleanup: Theme Builder list as before, no template enabled, options restored, no post/meta/term/log/pod/user/role left"
|
||||
|
||||
finish
|
||||
@@ -105,6 +105,16 @@ final class ComposeFixture
|
||||
{
|
||||
return $id !== 666;
|
||||
}
|
||||
|
||||
/** Ruling P10: 840 is a post, 666 no link target, 667 a readable attachment (none), every other ID a page. */
|
||||
public function linkTargetType(int $id): ?string
|
||||
{
|
||||
return match ($id) {
|
||||
666, 667 => null,
|
||||
840 => 'post',
|
||||
default => 'page',
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
use LIT\MCP\Integrations\Divi\Library\SimilarSections;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
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\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use Mockery;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
@@ -100,7 +102,9 @@ final class DiviWriterFixture
|
||||
array &$failures = [],
|
||||
bool $unfiltered = false,
|
||||
?DiviPageLoader $loader = null,
|
||||
?GlobalEdits $globals = null
|
||||
?GlobalEdits $globals = null,
|
||||
?ThemeBuilderEdits $themeBuilder = null,
|
||||
?SimilarSections $similar = null
|
||||
): DiviWriter {
|
||||
$design = self::designReader();
|
||||
|
||||
@@ -110,7 +114,9 @@ final class DiviWriterFixture
|
||||
self::log(),
|
||||
$design,
|
||||
self::composition($design, $unfiltered),
|
||||
$globals
|
||||
$globals,
|
||||
$themeBuilder,
|
||||
$similar
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ final class LibraryFixtures
|
||||
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
||||
);
|
||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.test/wp-admin/' . $path);
|
||||
}
|
||||
|
||||
/** A store that answers isGlobal()/layoutType() from ITEMS (no database). */
|
||||
@@ -118,12 +119,15 @@ final class LibraryFixtures
|
||||
*
|
||||
* @param list<array{id: int, status: string, type: string}>|\Closure(int): list<array{id: int, status: string, type: string}> $rows
|
||||
*/
|
||||
public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages
|
||||
{
|
||||
public static function usages(
|
||||
array|\Closure $rows,
|
||||
?DiviRoles $roles = null,
|
||||
?\Closure $themeBuilder = null
|
||||
): GlobalUsages {
|
||||
Functions\when('_get_non_cached_ids')->returnArg(1);
|
||||
Functions\when('_prime_post_caches')->justReturn(null);
|
||||
Functions\when('wp_cache_delete')->justReturn(true);
|
||||
$usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial();
|
||||
$usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles(), $themeBuilder])->makePartial();
|
||||
$usages->allows('rows')->andReturnUsing(static fn (int $id): array => is_array($rows) ? $rows : $rows($id));
|
||||
|
||||
return $usages;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* The Theme Builder of the tests: the real builder posts of tests/Fixtures/divi/stage3 (live TB post 1064,
|
||||
* default template 1065, template 1066 with header 1061, body 1062, footer 1063 – both disabled), a store
|
||||
* answering from Template values and the access built from them. Write methods of the store are real
|
||||
* unless a test mocks them.
|
||||
*/
|
||||
final class ThemeBuilderFixtures
|
||||
{
|
||||
public const THEME_BUILDER = 1064;
|
||||
public const DEFAULT_TEMPLATE = 1065;
|
||||
public const TEMPLATE = 1066;
|
||||
public const HEADER = 1061;
|
||||
public const BODY = 1062;
|
||||
public const FOOTER = 1063;
|
||||
|
||||
/** @var array<int, array{string, string}> Layout ID => [stage3 fixture file, post type]. */
|
||||
public const LAYOUTS = [
|
||||
1061 => ['tb-header--globaler-header.html', 'et_header_layout'],
|
||||
1062 => ['tb-body--body-mit-dynamic-content.html', 'et_body_layout'],
|
||||
1063 => ['tb-footer--footer.html', 'et_footer_layout'],
|
||||
];
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
public static function layout(int $id, array $fields = []): WP_Post
|
||||
{
|
||||
return new WP_Post(array_merge([
|
||||
'ID' => $id,
|
||||
'post_type' => self::LAYOUTS[$id][1],
|
||||
'post_status' => 'publish',
|
||||
'post_title' => '[Fixture] ' . $id,
|
||||
'post_content' => DiviFixtures::markup('stage3/' . self::LAYOUTS[$id][0]),
|
||||
], $fields));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
public static function templatePost(int $id = self::TEMPLATE, array $fields = []): WP_Post
|
||||
{
|
||||
return new WP_Post(array_merge([
|
||||
'ID' => $id,
|
||||
'post_type' => DiviThemeBuilder::TEMPLATE,
|
||||
'post_status' => 'publish',
|
||||
'post_title' => $id === self::DEFAULT_TEMPLATE ? 'Standard-Website-Template' : '[Fixture] Vorlage Globale Instanzen',
|
||||
'post_content' => '',
|
||||
], $fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* The template as Divi reads it (et_theme_builder_get_template); $overrides replace keys of that
|
||||
* array and may set status, attached and marked_unused.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
public static function template(int $id = self::TEMPLATE, array $overrides = []): Template
|
||||
{
|
||||
$base = $id === self::DEFAULT_TEMPLATE
|
||||
? [
|
||||
'id' => $id,
|
||||
'title' => 'Standard-Website-Template',
|
||||
'default' => true,
|
||||
'enabled' => false,
|
||||
'layouts' => [
|
||||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||||
'body' => ['id' => 0, 'enabled' => true],
|
||||
'footer' => ['id' => 0, 'enabled' => true],
|
||||
],
|
||||
'use_on' => [],
|
||||
'exclude_from' => [],
|
||||
]
|
||||
: [
|
||||
'id' => $id,
|
||||
'title' => '[Fixture] Vorlage Globale Instanzen',
|
||||
'default' => false,
|
||||
'enabled' => false,
|
||||
'layouts' => [
|
||||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||||
'body' => ['id' => self::BODY, 'enabled' => true],
|
||||
'footer' => ['id' => self::FOOTER, 'enabled' => true],
|
||||
],
|
||||
'use_on' => ['singular:post_type:page:id:1053'],
|
||||
'exclude_from' => [],
|
||||
];
|
||||
|
||||
return Template::fromDivi(
|
||||
array_merge($base, array_intersect_key($overrides, $base)),
|
||||
(string) ($overrides['status'] ?? 'publish'),
|
||||
(bool) ($overrides['attached'] ?? true),
|
||||
isset($overrides['marked_unused']) ? (string) $overrides['marked_unused'] : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A store whose live list is $templates, with a TB draft when $draftId > 0, $detached as templates no
|
||||
* Theme Builder post lists and $byIdOnly as templates template() finds but no list names (e.g. the one a
|
||||
* test lets storeTemplate() create).
|
||||
*
|
||||
* @param list<Template> $templates
|
||||
* @param list<Template> $detached
|
||||
* @param list<Template> $byIdOnly
|
||||
*/
|
||||
public static function store(
|
||||
array $templates = [],
|
||||
int $draftId = 0,
|
||||
array $detached = [],
|
||||
array $byIdOnly = []
|
||||
): DiviThemeBuilder {
|
||||
$byId = [];
|
||||
foreach (array_merge($templates, $detached, $byIdOnly) as $template) {
|
||||
$byId[$template->id] = $template;
|
||||
}
|
||||
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
|
||||
$store->allows('liveId')->andReturn(self::THEME_BUILDER);
|
||||
$store->allows('draftId')->andReturn($draftId);
|
||||
$store->allows('liveTemplates')->andReturn($templates);
|
||||
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
|
||||
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault();
|
||||
$store->allows('detachedIds')->andReturnUsing(static fn (int $limit): array => array_slice(
|
||||
array_map(static fn (Template $t): int => $t->id, $detached),
|
||||
0,
|
||||
$limit
|
||||
))->byDefault();
|
||||
$store->allows('isListedAnywhere')->andReturnUsing(
|
||||
static fn (int $id): bool => in_array($id, array_map(static fn (Template $t): int => $t->id, $templates), true)
|
||||
)->byDefault();
|
||||
$store->allows('flatConditions')->andReturn(self::conditions());
|
||||
|
||||
return $store;
|
||||
}
|
||||
|
||||
/** @param list<string> $deniedRoles Divi role options the user lacks. */
|
||||
public static function access(
|
||||
array $deniedRoles = [],
|
||||
bool $writable = true,
|
||||
?DiviThemeBuilder $store = null,
|
||||
bool $visible = true
|
||||
): ThemeBuilderAccess {
|
||||
return new ThemeBuilderAccess(
|
||||
$store ?? self::store(),
|
||||
LibraryFixtures::roles($deniedRoles),
|
||||
static fn (): bool => $writable,
|
||||
static fn (): bool => $visible
|
||||
);
|
||||
}
|
||||
|
||||
/** A loader with the library access of LibraryFixtures and $access (default: full TB rights, empty live list). */
|
||||
public static function loader(?ThemeBuilderAccess $access = null): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), $access ?? self::access());
|
||||
}
|
||||
|
||||
/**
|
||||
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
|
||||
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
|
||||
*
|
||||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||||
*/
|
||||
public static function conditions(): array
|
||||
{
|
||||
return [
|
||||
'singular:post_type:page:all' => ['label' => 'Alle Seiten', 'type' => null, 'value' => null],
|
||||
'homepage' => ['label' => 'Startseite', 'type' => null, 'value' => null],
|
||||
'singular:post_type:page:id:' => ['label' => 'Bestimmte Seiten', 'type' => 'post_type', 'value' => 'page'],
|
||||
'singular:post_type:page:children:id:' => [
|
||||
'label' => 'Unterseiten bestimmter Seiten', 'type' => 'post_type', 'value' => 'page',
|
||||
],
|
||||
'singular:post_type:lit_person:all' => ['label' => 'Alle Personen', 'type' => null, 'value' => null],
|
||||
'singular:post_type:lit_person:id:' => [
|
||||
'label' => 'Bestimmte Personen', 'type' => 'post_type', 'value' => 'lit_person',
|
||||
],
|
||||
'archive:taxonomy:category:term:id:' => [
|
||||
'label' => 'Bestimmte Kategorien', 'type' => 'taxonomy', 'value' => 'category',
|
||||
],
|
||||
'archive:user:id:' => ['label' => 'Bestimmte Autoren', 'type' => 'user', 'value' => null],
|
||||
'archive:user:role:' => ['label' => 'Autoren mit Rolle', 'type' => 'user_role', 'value' => null],
|
||||
'search' => ['label' => 'Suchergebnisse', 'type' => null, 'value' => null],
|
||||
'404' => ['label' => '404-Seite', 'type' => null, 'value' => null],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,58 @@ final class InstructionsBuilderTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
private function themeBuilder(): Integration
|
||||
{
|
||||
$group = Mockery::mock(Integration::class);
|
||||
$group->allows('id')->andReturn('divi-theme-builder');
|
||||
$group->allows('isAvailable')->andReturn(true);
|
||||
$activate = $this->ability('lit/tb-activate', false);
|
||||
$activate->allows('annotations')->andReturn(['readonly' => false]);
|
||||
$group->allows('abilities')->andReturn([$activate]);
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
public function testAddsTheThemeBuilderRuleOnlyWhenItsGroupCanWrite(): void
|
||||
{
|
||||
$core = $this->coreWith($this->ability('lit/guide', true));
|
||||
$registry = new IntegrationRegistry($core, $this->themeBuilder());
|
||||
|
||||
$with = (new InstructionsBuilder($registry))->build();
|
||||
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
|
||||
|
||||
self::assertStringContainsString('Divi Theme Builder: neue Vorlagen entstehen deaktiviert', $with);
|
||||
self::assertStringContainsString('lit/tb-activate', $with);
|
||||
self::assertStringContainsString('confirm: true', $with);
|
||||
self::assertStringContainsString('topic "theme-builder"', $with);
|
||||
self::assertStringNotContainsString('Divi Theme Builder:', $without);
|
||||
self::assertLessThan(2000, strlen($with));
|
||||
|
||||
foreach ([['@readonly:divi-theme-builder' => true], ['@group:divi-theme-builder' => 'disabled']] as $settings) {
|
||||
$policy = (new ExposurePolicy($settings))->withGroups($registry->groupMap());
|
||||
|
||||
$text = (new InstructionsBuilder($registry, null, $policy))->build();
|
||||
|
||||
self::assertStringNotContainsString('Divi Theme Builder:', $text, (string) json_encode($settings));
|
||||
}
|
||||
}
|
||||
|
||||
public function testAllRulesTogetherStayShort(): void
|
||||
{
|
||||
$divi = Mockery::mock(Integration::class);
|
||||
$divi->allows('id')->andReturn('divi');
|
||||
$divi->allows('isAvailable')->andReturn(true);
|
||||
$divi->allows('abilities')->andReturn([$this->ability('lit/divi-outline', true)]);
|
||||
$core = $this->coreWith($this->ability('lit/guide', true));
|
||||
$registry = new IntegrationRegistry($core, $divi, $this->library(), $this->themeBuilder());
|
||||
|
||||
$text = (new InstructionsBuilder($registry, static fn (): array => ['page', 'post']))->build();
|
||||
|
||||
self::assertStringContainsString('Divi-Bibliothek:', $text);
|
||||
self::assertStringContainsString('Divi Theme Builder:', $text);
|
||||
self::assertLessThan(2000, strlen($text));
|
||||
}
|
||||
|
||||
public function testListsPostTypesWhenAProviderIsGiven(): void
|
||||
{
|
||||
$core = $this->coreWith($this->ability('lit/guide', true));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FingerprintTest extends TestCase
|
||||
{
|
||||
/** The section of library entry 1055: section > row > column (24_24) > blurb. */
|
||||
private static function section(): Block
|
||||
{
|
||||
$content = DiviFixtures::markup('stage3/library--library-abschnitt.html');
|
||||
|
||||
return LibraryContent::roots(LibraryContent::load(new Parser(), $content))[0];
|
||||
}
|
||||
|
||||
private static function blurbOf(Block $section): Block
|
||||
{
|
||||
return $section->children()[0]->children()[0]->children()[0];
|
||||
}
|
||||
|
||||
public function testAdminLabelsAndBuilderVersionDoNotCount(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$attrs = $b->attrs();
|
||||
$attrs['module']['meta']['adminLabel']['desktop']['value'] = 'Anderes Label';
|
||||
$attrs['builderVersion'] = '5.3.0';
|
||||
$b->setAttrs($attrs);
|
||||
|
||||
self::assertSame(16, strlen(Fingerprint::content($a)));
|
||||
self::assertSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
}
|
||||
|
||||
public function testOtherTextsKeepTheStructureButNotTheContent(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$blurb = self::blurbOf($b);
|
||||
$attrs = $blurb->attrs();
|
||||
$attrs['title']['innerContent']['desktop']['value'] = 'Anderer Titel';
|
||||
$blurb->setAttrs($attrs);
|
||||
|
||||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
self::assertNotSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||
}
|
||||
|
||||
public function testColumnLayoutAndModuleTypesChangeTheStructure(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$column = $b->children()[0]->children()[0];
|
||||
$attrs = $column->attrs();
|
||||
$attrs['module']['decoration']['sizing']['desktop']['value']['flexType'] = '12_24';
|
||||
$column->setAttrs($attrs);
|
||||
$c = Block::create('divi/section', [], [Block::create('divi/row', [], [Block::create('divi/column', [], [
|
||||
Block::create('divi/text'),
|
||||
])])]);
|
||||
|
||||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
|
||||
}
|
||||
|
||||
/** The section of 1055 with $extra inserted at the end of its column (review of Task 15, ruling Q25). */
|
||||
private static function sectionWith(string $extra): Block
|
||||
{
|
||||
$markup = DiviFixtures::markup('stage3/library--library-abschnitt.html');
|
||||
$at = strrpos($markup, '<!-- /wp:divi/column -->');
|
||||
$markup = substr($markup, 0, (int) $at) . $extra . substr($markup, (int) $at);
|
||||
|
||||
return LibraryContent::roots(LibraryContent::load(new Parser(), $markup))[0];
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function visibleExtras(): array
|
||||
{
|
||||
return [
|
||||
'raw html between blocks' => ['<p>ONLY ON B</p>', '<p>ONLY ON C</p>'],
|
||||
'core paragraph' => [
|
||||
'<!-- wp:paragraph --><p>ONLY ON B</p><!-- /wp:paragraph -->',
|
||||
'<!-- wp:paragraph --><p>ONLY ON C</p><!-- /wp:paragraph -->',
|
||||
],
|
||||
'divi 4 shortcode text' => ['[et_pb_text]ONLY ON B[/et_pb_text]', '[et_pb_text]ONLY ON C[/et_pb_text]'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q25: text parts are visible – raw HTML between blocks and the inner HTML of core blocks count
|
||||
* for the content, like attributes.
|
||||
*
|
||||
* @dataProvider visibleExtras
|
||||
*/
|
||||
public function testVisibleTextPartsCountForTheContent(string $extra, string $other): void
|
||||
{
|
||||
$plain = self::section();
|
||||
|
||||
self::assertNotSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith($extra)));
|
||||
self::assertNotSame(
|
||||
Fingerprint::content(self::sectionWith($extra)),
|
||||
Fingerprint::content(self::sectionWith($other))
|
||||
);
|
||||
self::assertNotSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith($extra)));
|
||||
}
|
||||
|
||||
public function testRawTextKeepsTheStructureAndWhitespaceNeverCounts(): void
|
||||
{
|
||||
$plain = self::section();
|
||||
|
||||
self::assertSame(Fingerprint::structure($plain), Fingerprint::structure(self::sectionWith('<p>x</p>')));
|
||||
self::assertSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith("\n \n")));
|
||||
self::assertSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith("\n\t")));
|
||||
}
|
||||
|
||||
/** Opaque blocks count with all their bytes (a global instance's snapshot included). */
|
||||
public function testOpaqueBlocksCountWithAllTheirBytes(): void
|
||||
{
|
||||
$instance = static fn (string $text): Block => LibraryContent::roots(LibraryContent::load(
|
||||
new Parser(),
|
||||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text"} --><p>' . $text
|
||||
. '</p><!-- /wp:divi/global-layout -->'
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||||
))[0];
|
||||
|
||||
self::assertNotSame(Fingerprint::content($instance('a')), Fingerprint::content($instance('b')));
|
||||
}
|
||||
}
|
||||
@@ -183,4 +183,27 @@ final class OutlineTest extends TestCase
|
||||
{
|
||||
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
|
||||
}
|
||||
|
||||
/** Ruling P13: without library access the outline says "global", but not which entry. */
|
||||
public function testGlobalIdsCanBeHidden(): void
|
||||
{
|
||||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||||
$outline = new Outline([], false);
|
||||
$rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));
|
||||
|
||||
$byId = array_column($rows, null, 'id');
|
||||
self::assertNull($byId['g0']['global_id']);
|
||||
self::assertSame('section', $byId['g0']['type']);
|
||||
self::assertStringNotContainsString('1058', $outline->text($rows));
|
||||
self::assertStringNotContainsString('1059', $outline->text($rows));
|
||||
self::assertStringNotContainsString('[global', $outline->text($rows));
|
||||
self::assertStringContainsString('g0 section "Global Abschnitt" (nur lesbar)', $outline->text($rows));
|
||||
self::assertStringNotContainsString('1058', (string) json_encode($rows));
|
||||
self::assertStringNotContainsString('1059', (string) json_encode($rows));
|
||||
$summary = $outline->summary(NodeIndex::build((new Parser())->parse($content)));
|
||||
self::assertStringNotContainsString('1058', (string) $summary['text']);
|
||||
|
||||
$shown = new Outline();
|
||||
self::assertStringContainsString('[global 1058]', $shown->text($shown->rows(NodeIndex::build((new Parser())->parse($content)))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** The Etappe 3b builder fixtures (tests/Fixtures/divi/stage3b, README): real Divi data with Pods fields. */
|
||||
final class Stage3bFixturesTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string}> */
|
||||
public static function files(): array
|
||||
{
|
||||
$files = glob(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/*.html') ?: [];
|
||||
$cases = [];
|
||||
foreach ($files as $file) {
|
||||
$cases[basename($file)] = ['stage3b/' . basename($file)];
|
||||
}
|
||||
|
||||
return $cases;
|
||||
}
|
||||
|
||||
public function testTheFixturesExist(): void
|
||||
{
|
||||
self::assertArrayHasKey('tb-body--person-body.html', self::files());
|
||||
self::assertFileExists(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/tb-template--person.json');
|
||||
}
|
||||
|
||||
/** @dataProvider files */
|
||||
public function testParsesAndSerializesByteIdentically(string $name): void
|
||||
{
|
||||
$content = DiviFixtures::markup($name);
|
||||
|
||||
self::assertSame($content, Serializer::document((new Parser())->parse($content)));
|
||||
}
|
||||
|
||||
/** Finding 12: the Pods field "role" as Divi's dialog writes it (custom_meta_role or post_meta_key + meta_key). */
|
||||
public function testBodyLayoutShowsThePodsFieldRole(): void
|
||||
{
|
||||
$body = DiviFixtures::markup('stage3b/tb-body--person-body.html');
|
||||
|
||||
self::assertTrue(
|
||||
str_contains($body, 'custom_meta_role') || str_contains($body, 'meta_key":"role"'),
|
||||
'the body layout references the Pods field role'
|
||||
);
|
||||
self::assertStringContainsString('wp:divi/post-content', $body);
|
||||
}
|
||||
|
||||
public function testTheTemplateIsDisabledAndMeantForPersons(): void
|
||||
{
|
||||
$template = json_decode(DiviFixtures::markup('stage3b/tb-template--person.json'), true);
|
||||
|
||||
self::assertSame(['0'], $template['meta']['_et_enabled']);
|
||||
self::assertSame(['singular:post_type:lit_fixture_person:all'], $template['meta']['_et_use_on']);
|
||||
self::assertSame(['1061'], $template['meta']['_et_header_layout_id'], 'global header of the default template');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PostIdParserTest extends TestCase
|
||||
{
|
||||
public function testAcceptsPositiveIdsAsIntOrDigits(): void
|
||||
{
|
||||
self::assertSame(839, PostIdParser::parse(839));
|
||||
self::assertSame(839, PostIdParser::parse('839'));
|
||||
self::assertSame(PHP_INT_MAX, PostIdParser::parse((string) PHP_INT_MAX));
|
||||
}
|
||||
|
||||
/** Ruling P10: one rule for {{link:}} and post_link_url_* – no zero, sign, padding, slug or overflow. */
|
||||
public function testRefusesEverythingElse(): void
|
||||
{
|
||||
$bad = [
|
||||
0, -3, '0', '-3', '+3', '0839', '8 39', ' 839', '839 ', "839\n", '839.0', '0x1f', '1e3', 'impressum',
|
||||
'99999999999999999999', '9223372036854775808', 1.5, 839.0, null, [], true, false,
|
||||
];
|
||||
foreach ($bad as $value) {
|
||||
self::assertNull(PostIdParser::parse($value), var_export($value, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,6 +415,11 @@ final class StoredNodeGuardTest extends TestCase
|
||||
{
|
||||
return $this->inner->canReadPost($id);
|
||||
}
|
||||
|
||||
public function linkTargetType(int $id): ?string
|
||||
{
|
||||
return $this->inner->linkTargetType($id);
|
||||
}
|
||||
};
|
||||
$references = new VariableRef(
|
||||
DiviFixtures::designSystem(),
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -76,4 +78,19 @@ final class StructureRulesTest extends TestCase
|
||||
self::assertFalse($rules->isPlainModule('divi/accordion-item'));
|
||||
self::assertFalse($rules->isPlainModule('divi/row'));
|
||||
}
|
||||
|
||||
/** F22: Divi renders divi/post-content only in body layouts (appearance.themeBuilderLayouts). */
|
||||
public function testModulesLimitedToThemeBuilderAreas(): void
|
||||
{
|
||||
$catalog = DiviFixtures::catalog();
|
||||
$column = Block::create('divi/column');
|
||||
|
||||
self::assertNull((new StructureRules($catalog, 'body'))->violation($column, 'divi/post-content'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
(new StructureRules($catalog, 'header'))->violation($column, 'divi/post-content')
|
||||
);
|
||||
self::assertNull((new StructureRules($catalog))->violation($column, 'divi/post-content'), 'pages: Divi decides');
|
||||
self::assertNull((new StructureRules($catalog, 'footer'))->violation($column, 'divi/text'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TreeEditorTest extends TestCase
|
||||
@@ -220,4 +222,26 @@ final class TreeEditorTest extends TestCase
|
||||
$editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F22 for stored nodes (node-copy, library-insert, duplicate): a copied section that holds divi/post-content
|
||||
* is refused outside a body layout, however deep the module sits.
|
||||
*/
|
||||
public function testInsertChecksTheThemeBuilderAreaOfNestedModules(): void
|
||||
{
|
||||
$body = (new Parser())->parse(DiviFixtures::markup('stage3/tb-body--body-mit-dynamic-content.html'));
|
||||
$section = NodeIndex::build($body)->find('s0')?->copy();
|
||||
self::assertInstanceOf(Block::class, $section);
|
||||
|
||||
$header = (new Parser())->parse(DiviFixtures::markup('stage3/tb-header--globaler-header.html'));
|
||||
$headerIndex = NodeIndex::build($header);
|
||||
$editor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'header'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
$editor->insert($editor->position($headerIndex, []), [$section->copy()])
|
||||
);
|
||||
|
||||
$bodyEditor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'body'));
|
||||
self::assertNull($bodyEditor->insert($bodyEditor->position(NodeIndex::build($body), []), [$section->copy()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,64 @@ final class VariableRefTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruling P10: a full-form internal link follows the {{link:}} target rules, its type included. */
|
||||
public function testFullFormInternalLinksFollowTheLinkTargetRules(): void
|
||||
{
|
||||
$ref = self::ref([
|
||||
'post_link_url_page' => ['post_id' => 'select_post'],
|
||||
'post_link_url_post' => ['post_id' => 'select_post'],
|
||||
]);
|
||||
$sanitizer = ComposeFixture::sanitizer();
|
||||
|
||||
self::assertSame(
|
||||
self::content('post_link_url_post', '{"post_id":"840"}'),
|
||||
$ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true)
|
||||
);
|
||||
self::assertSame(
|
||||
self::content('post_link_url_post', '{"post_id":840}'),
|
||||
$ref->canonical(self::content('post_link_url_post', '{"post_id":840}'), $sanitizer, true)
|
||||
);
|
||||
$refused = [
|
||||
'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'),
|
||||
'a post is no page (int)' => self::content('post_link_url_page', '{"post_id":840}'),
|
||||
'a page is no post' => self::content('post_link_url_post', '{"post_id":"12"}'),
|
||||
'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'),
|
||||
'attachment' => self::content('post_link_url_page', '{"post_id":"667"}'),
|
||||
'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'),
|
||||
'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'),
|
||||
'float id' => self::content('post_link_url_page', '{"post_id":12.0}'),
|
||||
'bool id' => self::content('post_link_url_page', '{"post_id":true}'),
|
||||
];
|
||||
foreach ($refused as $case => $value) {
|
||||
try {
|
||||
$ref->canonical($value, $sanitizer, true);
|
||||
self::fail($case . ' was accepted');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruling S37/P10: select_post outside an internal link stays "a post the user may read", of any type. */
|
||||
public function testOtherPostSettingsOnlyNeedAReadablePost(): void
|
||||
{
|
||||
$ref = self::ref(['evil' => ['p' => 'select_post']]);
|
||||
$sanitizer = ComposeFixture::sanitizer();
|
||||
|
||||
self::assertSame(
|
||||
self::content('evil', '{"p":"840"}'),
|
||||
$ref->canonical(self::content('evil', '{"p":"840"}'), $sanitizer)
|
||||
);
|
||||
foreach (['"666"', '"0840"', '"99999999999999999999"'] as $id) {
|
||||
try {
|
||||
$ref->canonical(self::content('evil', '{"p":' . $id . '}'), $sanitizer);
|
||||
self::fail($id . ' was accepted');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** E13a: an internal link (post_link_url_<type>) is a URL – refused in every other field. */
|
||||
public function testInternalLinksOnlyInUrlFields(): void
|
||||
{
|
||||
@@ -461,4 +519,12 @@ final class VariableRefTest extends TestCase
|
||||
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
|
||||
$ref->canonical($link, ComposeFixture::sanitizer());
|
||||
}
|
||||
|
||||
public function testNameOfReadsTheContentOptionOnly(): void
|
||||
{
|
||||
self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}')));
|
||||
self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$'));
|
||||
self::assertNull(VariableRef::nameOf('Text'));
|
||||
self::assertNull(VariableRef::nameOf('$variable(kaputt)$'));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user