From 2c553d2f0bfc601eedab5445e2b133d0ba8d4115 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 22:25:13 +0200 Subject: [PATCH 01/47] docs: readme and guides name no specific chatbot Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 2 +- readme.txt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 28791d1..d20f77f 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -28,7 +28,7 @@ Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/dea („wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“ schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten -Tools warnt die Seite (Kontextkosten in claude.ai). Änderungen gelten ab der nächsten Verbindung. +Tools warnt die Seite (Kontextkosten im Assistenten). Änderungen gelten ab der nächsten Verbindung. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): diff --git a/readme.txt b/readme.txt index d04e238..9593e5f 100644 --- a/readme.txt +++ b/readme.txt @@ -1,6 +1,6 @@ === WP-LIT-MCP === Contributors: loosenit -Tags: mcp, claude, divi, pods, ai +Tags: mcp, ai, divi, pods, oauth Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 8.1 @@ -8,11 +8,11 @@ Stable tag: 0.3.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html -MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. +MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten. == Description == -WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt: +WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth) diese Website pflegt: Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen WordPress-Benutzers und wird protokolliert. @@ -29,7 +29,7 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, * mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln, die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen). -* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im +* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. der Name des Assistenten). Es ist im Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar. * Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die @@ -115,7 +115,7 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht). - Wer Claude sicher trennen will, widerruft die Verbindungen vorher unter Zugriff. + Wer den Assistenten sicher trennen will, widerruft die Verbindungen vorher unter Zugriff. * Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website) sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern. * Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option @@ -138,10 +138,10 @@ abgeschaltet. Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden Tools einer Gruppe auf einmal ab. -= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? = += Bleiben vom Assistenten bearbeitete Divi-Seiten im Visual Builder bearbeitbar? = Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem -Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor es weiter ändert. +Designsystem. Nach einer Änderung im Builder liest der Assistent die Seite neu, bevor er weiter ändert. == Changelog == From e7cc4efb1169f0e851ae49e17f83498ebead6675 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 23:05:02 +0200 Subject: [PATCH 02/47] test: real Divi 5 stage 3 fixtures (library, global instances, Theme Builder) Created through Divi's own save paths (Visual Builder add-to-library dialog, divi/v1/divi-library/create-item, divi/v1/sync-to-server, Theme Builder admin-ajax create_layout/save) and exported by divi-export-stage3-fixtures.php. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/Fixtures/divi/stage3/README.md | 114 +++++++++++ .../stage3/library--layout-ganze-seite.html | 27 +++ .../stage3/library--layout-ganze-seite.json | 51 +++++ .../stage3/library--library-abschnitt.html | 7 + .../stage3/library--library-abschnitt.json | 45 +++++ .../divi/stage3/library--library-modul.html | 1 + .../divi/stage3/library--library-modul.json | 54 +++++ .../divi/stage3/library--library-zeile.html | 5 + .../divi/stage3/library--library-zeile.json | 48 +++++ .../library-global--globaler-abschnitt.html | 9 + .../library-global--globaler-abschnitt.json | 45 +++++ .../library-global--globales-modul.html | 1 + .../library-global--globales-modul.json | 51 +++++ tests/Fixtures/divi/stage3/manifest.json | 17 ++ .../divi/stage3/page--globale-instanzen.html | 27 +++ .../divi/stage3/page--globale-instanzen.json | 71 +++++++ .../tb-body--body-mit-dynamic-content.html | 13 ++ .../tb-body--body-mit-dynamic-content.json | 71 +++++++ .../divi/stage3/tb-footer--footer.html | 7 + .../divi/stage3/tb-footer--footer.json | 71 +++++++ .../stage3/tb-header--globaler-header.html | 9 + .../stage3/tb-header--globaler-header.json | 71 +++++++ .../divi/stage3/tb-template--default.json | 47 +++++ ...b-template--vorlage-globale-instanzen.json | 50 +++++ tests/Fixtures/divi/stage3/theme-builder.json | 20 ++ .../divi-export-stage3-fixtures.php | 185 ++++++++++++++++++ 26 files changed, 1117 insertions(+) create mode 100644 tests/Fixtures/divi/stage3/README.md create mode 100644 tests/Fixtures/divi/stage3/library--layout-ganze-seite.html create mode 100644 tests/Fixtures/divi/stage3/library--layout-ganze-seite.json create mode 100644 tests/Fixtures/divi/stage3/library--library-abschnitt.html create mode 100644 tests/Fixtures/divi/stage3/library--library-abschnitt.json create mode 100644 tests/Fixtures/divi/stage3/library--library-modul.html create mode 100644 tests/Fixtures/divi/stage3/library--library-modul.json create mode 100644 tests/Fixtures/divi/stage3/library--library-zeile.html create mode 100644 tests/Fixtures/divi/stage3/library--library-zeile.json create mode 100644 tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html create mode 100644 tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json create mode 100644 tests/Fixtures/divi/stage3/library-global--globales-modul.html create mode 100644 tests/Fixtures/divi/stage3/library-global--globales-modul.json create mode 100644 tests/Fixtures/divi/stage3/manifest.json create mode 100644 tests/Fixtures/divi/stage3/page--globale-instanzen.html create mode 100644 tests/Fixtures/divi/stage3/page--globale-instanzen.json create mode 100644 tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html create mode 100644 tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json create mode 100644 tests/Fixtures/divi/stage3/tb-footer--footer.html create mode 100644 tests/Fixtures/divi/stage3/tb-footer--footer.json create mode 100644 tests/Fixtures/divi/stage3/tb-header--globaler-header.html create mode 100644 tests/Fixtures/divi/stage3/tb-header--globaler-header.json create mode 100644 tests/Fixtures/divi/stage3/tb-template--default.json create mode 100644 tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json create mode 100644 tests/Fixtures/divi/stage3/theme-builder.json create mode 100644 tests/Integration/divi-export-stage3-fixtures.php diff --git a/tests/Fixtures/divi/stage3/README.md b/tests/Fixtures/divi/stage3/README.md new file mode 100644 index 0000000..7a40732 --- /dev/null +++ b/tests/Fixtures/divi/stage3/README.md @@ -0,0 +1,114 @@ +# Divi-5-Fixtures für Etappe 3 (Library, globale Module, Theme Builder) + +Echte Daten aus Divi 5.2.1, erzeugt am 2026-09-26 auf der lokalen DDEV-Site (`https://wordpress.ddev.site:8443`). Alle Einträge wurden über **Divis eigene Speicherwege** angelegt – den echten Visual Builder und die echte Theme-Builder-Oberfläche in einem headless Chrome (Chrome 153, per DevTools-Protokoll gesteuert, angemeldet als Administrator). Weder Plugin-Code noch eigene Schreibwege in die Datenbank waren beteiligt; einzige Ausnahme ist `et_builder_enable_for_post()` für die neue Seite (siehe 1). + +Pfade zu Divi relativ zu `wp-content/themes/Divi/includes/`. Die Builder-JS-Dateien liegen in `builder-5/visual-builder/build/` und sind minifiziert – dort steht statt einer Zeilennummer ein Suchbegriff. + +## Dateien + +Je Post eine `.json` (Post-Zeile, **alle** Meta-Einträge roh in Speicherreihenfolge, alle Term-Beziehungen direkt aus den Tabellen) und, falls Inhalt vorhanden, `.html` mit `post_content` byte-genau. `manifest.json` ordnet Rolle → Post-ID zu, `theme-builder.json` enthält den Live-Theme-Builder-Post mit seiner Vorlagenliste, die `et_tb_*`-Optionen und die als ungenutzt markierten Posts. + +| Rolle | ID | Typ | Inhalt | +|---|---|---|---| +| `page--globale-instanzen` | 1053 | `page` (Entwurf) | eine Instanz des globalen Abschnitts, eine Instanz des globalen Moduls, normale Abschnitte | +| `library--layout-ganze-seite` | 1054 | `et_pb_layout` | ganzes Seitenlayout (vor dem Globalmachen gespeichert), Kategorie „Fixture“ | +| `library--library-abschnitt` | 1055 | `et_pb_layout` | Abschnitt, nicht global | +| `library--library-zeile` | 1056 | `et_pb_layout` | Zeile, nicht global | +| `library--library-modul` | 1057 | `et_pb_layout` | Blurb-Modul, nicht global, Schlagwort „fixture“ | +| `library-global--globaler-abschnitt` | 1058 | `et_pb_layout` | globaler Abschnitt | +| `library-global--globales-modul` | 1059 | `et_pb_layout` | globales Text-Modul | +| `tb-header--globaler-header` | 1061 | `et_header_layout` | globaler Header (Header der Standardvorlage) | +| `tb-body--body-mit-dynamic-content` | 1062 | `et_body_layout` | Dynamic Content: Titel, Meta-Feld, Auszug, Modul „Beitragsinhalt“ | +| `tb-footer--footer` | 1063 | `et_footer_layout` | Footer | +| `tb-template--default` | 1065 | `et_template` | „Standard-Website-Template“, **deaktiviert**, Header = 1061 | +| `tb-template--vorlage-globale-instanzen` | 1066 | `et_template` | **deaktiviert**, Header 1061 / Body 1062 / Footer 1063, Bedingung `singular:post_type:page:id:1053` | +| (theme-builder.json) | 1064 | `et_theme_builder` | Live-TB-Post, `_et_template` = [1065, 1066] | + +Nicht exportiert: die Revision 1060 der Seite. + +## Wie die Fixtures entstanden sind + +### 1. Seite „[Fixture] Globale Instanzen“ (1053) + +- Angelegt als leerer Entwurf (`wp post create`), dann `et_builder_enable_for_post(1053)` (`builder/core.php:4438-4452`) – das tut der Button „Divi Builder verwenden“; ohne `_et_pb_use_builder` öffnet `et_fb=1` den Builder nicht (`et_fb_is_enabled`, `builder/core.php:4195-4273`). +- Visual Builder geöffnet (`?page_id=1053&et_fb=1`). Die App läuft im iframe `app_window=1`; dort `divi.data`. +- Inhalt in den Builder-Store gesetzt: `dispatch('divi/edit-post').setContent(flat)` (Aktion `SET_CONTENT`, `edit-post.js`, Suche `Ht=e=>({type:"SET_CONTENT"`). Die Modul-Attribute (Admin-Labels, Texte, `builderVersion 5.2.1`, Spalte `flexType 24_24`) haben **wir** gewählt – wie bei den Etappe-2-Seiten; es wurden keine Module per Klick eingefügt, Divis Standardattribute beim Einfügen fehlen also. +- Gespeichert wie der Button „Entwurf speichern“: `dispatch('divi/serialized-post').syncToServer({syncType:'draft'})` (`app-ui.js`, Suche `syncToServer({syncType:"draft"`) → Divis Serialisierer (`serialized-post.js`, Suche `k=(t,e="root",r="global-module")`) → `POST divi/v1/sync-to-server` (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:819-826`, `SyncToServerController::update`, `builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php:56`). + +### 2. Library-Einträge (1054–1059) + +Über den echten Dialog „Zur Bibliothek hinzufügen“: `dispatch('divi/modal-library').open({name:'divi/add-to-library', owner:})` (so öffnet ihn das Rechtsklickmenü, `right-click-options.js`, Suche `name:"divi/add-to-library",owner:e`; für das ganze Layout `owner:""` wie das Tastenkürzel in `root.js`). Name, neue Kategorie/Schlagwort und der Schalter „Als Global abspeichern“ wurden im Dialog-DOM gesetzt, dann „Zur Bibliothek hinzufügen“ geklickt. Damit lief Divis eigener `onSaveClick` (`modal-library.js`, Suche `onSaveClick:({moduleNameOption`): + +- Serialisierung `serializeModuleFlatObjects` (ganzes Layout mit Kontext `"layout"`), FormData mit `layout_type`, `layout_scope`, `module_type`, `module_width`, `columns_layout`, `post_type`; +- `POST divi/v1/divi-library/create-item` (`RESTRegistration.php:743-749`, `DiviLibraryController::create_item`, `builder-5/server/VisualBuilder/REST/DiviLibrary/DiviLibraryController.php:606-757`) → `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) → `et_pb_create_layout()` (`:738-800`); +- bei „global“ danach im Builder `Tn` (`modal-library.js`, Suche `Tn=({moduleId:e,templateId:t`): `setGlobalModuleAttribute(, )`, `setGlobalParentAttribute` für alle Nachkommen (`Nn`), `global-layouts.setLayoutContent`. Anschließend wurde die Seite gespeichert (siehe 1) – so entstanden die Instanzen in 1053. + +Reihenfolge: Layout 1054, Abschnitt 1055, Zeile 1056, Modul 1057, globaler Abschnitt 1058, globales Modul 1059, dann Seite speichern. + +### 3. Theme Builder (1061–1066) + +Auf der Seite `wp-admin/admin.php?page=et_theme_builder` mit den dort lokalisierten Nonces (`et_theme_builder_bundle.config.nonces`, `wp_localize_script` in `builder/frontend-builder/theme-builder/admin.php:129`) und jQuery, wie die TB-App (`builder/frontend-builder/build/theme-builder.js`, Suche `createLayout:function(e){return(0,l.post)` und `save:function(e,t)`): + +1. `et_theme_builder_api_create_layout` für `header`, `body`, `footer` (`theme-builder/api.php:12-46` → `et_theme_builder_insert_layout`, `theme-builder/theme-builder.php:1643-1667`) → 1061, 1062, 1063. +2. `et_theme_builder_api_save` mit `live=1` (`api.php:160-273` → `et_theme_builder_store_template`, `theme-builder.php:1463-1586`) mit zwei Vorlagen: Standardvorlage (Header 1061, **`enabled=0`**) und „[Fixture] Vorlage Globale Instanzen“ (Header 1061, Body 1062, Footer 1063, **`enabled=0`**, `use_on[]=singular:post_type:page:id:1053`). Divi hat dabei den Live-TB-Post 1064 angelegt (`et_theme_builder_get_theme_builder_post_id`, `theme-builder.php:378`), die Vorlagen 1065/1066 mit `publish` gespeichert und `_et_template` gesetzt. +3. `et_theme_builder_api_get_layout_url` (`api.php:116-147`) für die Builder-URLs; jede Layout-URL **ohne** `app_window=1` im Visual Builder geöffnet, Inhalt per `setContent` gesetzt, Titel „[Fixture] …“ über das Feld „Titel“ der Seiteneinstellungen eingetragen und mit `syncToServer({syncType:'publish'})` gespeichert (TB-Layouts sind immer `publish`) → `divi/v1/sync-to-server` wie oben. + +„Globaler Header“ heißt im Theme Builder: Header-Layout der Standardvorlage, das andere Vorlagen mit derselben ID mitbenutzen (1065 und 1066 zeigen beide auf 1061). Die Meta `_et_{header,body,footer}_layout_global` gibt es nur für TB-Library-Einträge, nicht für die Live-Vorlagen. + +### 4. Dynamic Content (Body-Layout 1062) + +Im Body-Layout: Überschrift = `post_title`, Text = `post_meta_key` mit manuellem Schlüssel `fixture_untertitel`, Text = `post_excerpt`, dazu das Modul `divi/post-content`. Die Werte haben die Form `$variable({"type":"content","value":{"name":"…","settings":{…}}})$` (`module.js`, Suche ``$variable(${JSON.stringify(s)})$``; Server: `builder-5/server/Packages/Module/Layout/Components/DynamicContent/`). + +## Was echt ist und was nachgebildet wurde + +**Echt aus Divi (JS-Serialisierer + Server):** gesamte Blockstruktur und Serialisierung aller `.html`, der `divi/placeholder`-Rahmen, die Form der globalen Instanzen, alle Library-Terme und -Meta, Post-Zeilen, alle Meta der Seite und der TB-Layouts, TB-Vorlagen-Meta, `_et_template` am TB-Post, der Umgang mit Kategorie/Schlagwort-Neuanlage. + +**Von uns vorgegeben bzw. aus JS nachgebildet:** + +- Die Modul-**Attribute** im Builder-Store (siehe 1). Divi hat sie unverändert serialisiert, aber nicht selbst erzeugt. +- Die Vorlagen-Nutzdaten für `et_theme_builder_api_save`: nachgebaut nach `formatTemplatesForStorage` (`theme-builder.js`, Suche `formatTemplatesForStorage=function`): `{id:, title, autogenerated_title:"0", default:"1"/"0", enabled:"1"/"0", layouts:{header|body|footer:{id, enabled:"1"}}, use_on:[…], exclude_from:[]}`, `hasDefault:"0"`, `first_request`/`last_request:"1"`. Der Server-Teil (Speichern, Zuordnung) ist echt. +- Die `settings` der Dynamic-Content-Werte: aus den `default`-Werten der Felder in `settings.dynamicContent.options` (so füllt der Einstellungsdialog vor) plus `meta_key`. Im echten Dialog können Schlüsselreihenfolge und ausgeblendete Felder (`custom_date_format`, `date_format` ohne Datumsfeld) abweichen. Die Namen (`post_title`, `post_meta_key`, `post_excerpt`) und die `$variable(…)$`-Hülle sind aus Divi belegt. + +## Format-Befunde (für Etappe 3 wichtig) + +1. **Der Visual Builder speichert anders als unsere bisherigen Seiten:** Zeilenumbrüche zwischen Blöcken, Module ohne Kinder **selbstschließend** (``, danach eine Leerzeile), `builderVersion` steht am Ende der Attribute; der `divi/placeholder`-Rahmen ohne Umbruch. Divis D4-Konvertierung und das Plugin schreiben dagegen gepaarte Kommentare ohne Umbrüche. Beide Formen kommen auf echten Seiten vor. Der Plugin-Parser liest alle zehn `.html` hier byte-identisch zurück (Parse → Serialize). +2. **Globale Instanz auf der Seite:** ``, darin die Kinder mit ihren **echten** Blocknamen und jeweils `"globalParent":"1058"`. Ein globales Modul ohne Kinder ist ein selbstschließender `divi/global-layout`-Block. `localAttrs` ist ein vollständiger Schnappschuss (kein leeres Objekt); `localAttrsMap`/`localChildren` fehlen, solange nichts entkoppelt ist. +3. **Globaler Library-Post** enthält normalen Inhalt ohne `globalModule`/`globalParent`; „global“ steckt nur im Term `scope=global` (beim Modul zusätzlich `_et_pb_module_type`, `_et_pb_excluded_global_options = "[]"`). +4. Library-Einträge bekommen **kein** `_et_pb_use_divi_5` und kein `_et_pb_use_builder` (bestätigt F3). Seiten und TB-Layouts nach VB-Speichern schon. +5. **Zeilen-Bug in Divi:** `_et_pb_row_layout` ist `"[object Object]"` (der Builder schickt `columns_layout` als stringifiziertes Objekt). Nicht als Wert verwenden. +6. **TB-Layouts haben keinen `layout_type`-Term**, obwohl `et_theme_builder_insert_layout` ihn setzen will (`theme-builder.php:1663`). In `wp_term_relationships` steht für 1061–1063 nichts; vermutlich ist die Taxonomie im admin-ajax-Aufruf nicht registriert (Registrierung über `ET_Builder_Library`, `builder/feature/Library.php:397`) – die Ursache ist nicht weiter geprüft. Nicht auf Terme bei TB-Layouts verlassen. +7. **TB-Bedingungen** stehen als mehrfaches Meta `_et_use_on` / `_et_exclude_from` mit IDs wie `singular:post_type:page:id:1053`. Die Vorlagenzuordnung ist `_et_template` (mehrfach) am `et_theme_builder`-Post; Vorlagen-Titel der Standardvorlage ist übersetzt („Standard-Website-Template“). +8. TB-Layouts heißen nach dem Anlegen alle „Theme Builder Layout“ mit Slug `theme-builder-layout` (in jedem Post-Typ gleich); erst der Titel im Builder benennt sie um. `_et_pb_post_settings_title` spiegelt den Titel. + +## Wirkung auf die Live-Site + +Vorher gab es keinen Theme-Builder-Post und keine Vorlagen. Jetzt existiert der Live-TB-Post 1064. Beide Vorlagen sind deaktiviert: `ET_Theme_Builder_Request::get_template()` überspringt deaktivierte Vorlagen (`theme-builder/ThemeBuilderRequest.php:393`) und nimmt die Standardvorlage nur, wenn sie aktiv ist (`:434`). Die Fixture-Vorlage wäre zudem nur für die Entwurfsseite 1053 zuständig. Geprüft: `get_template()` liefert für 1053 nichts, die Startseite enthält keinen Fixture-Header. Beim Live-Speichern hat Divi `et_theme_builder_trash_draft_and_unused_posts()` (`theme-builder.php:591`) ausgeführt; nichts wurde als ungenutzt markiert. **Achtung:** Wer im Theme Builder später speichert, speichert die komplette Vorlagenliste neu; Layouts, die dann nicht mehr zugeordnet sind, markiert Divi und löscht sie nach 7 Tagen. + +Neu angelegte Terme (vorher gab es keine Library-Terme): `layout_category` „Fixture“ (26), `layout_tag` „fixture“ (32) sowie Divis Systemterme `scope` (27, 34), `layout_type` (28, 30, 31, 33), `module_width` (29). + +## Neu exportieren + +``` +ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php +``` + +Exportiert werden nur Posts mit Titel „[Fixture] …“ und die Standardvorlage, wenn sie auf Fixture-Layouts zeigt. Das Skript löscht vorher alle `*.json`/`*.html` in diesem Ordner. + +## Aufräumen (lokal) + +Die Daten bleiben vorerst zum Ansehen stehen. Zum Entfernen (DDEV, WordPress-Wurzel; IDs vorher mit `manifest.json` abgleichen): + +``` +# Theme Builder: TB-Post (von den Fixtures neu angelegt), Vorlagen, Layouts +ddev wp post delete 1064 1065 1066 1061 1062 1063 --force +# Library-Einträge und Seite (Revision 1060 fällt mit weg) +ddev wp post delete 1054 1055 1056 1057 1058 1059 1053 --force +# Von den Fixtures angelegte Terme +ddev wp term delete layout_category 26 +ddev wp term delete layout_tag 32 +ddev wp eval 'foreach ([27,28,29,30,31,33,34] as $t) { wp_delete_term($t, get_term($t)->taxonomy); }' +# Divi-Caches +ddev wp eval 'et_theme_builder_clear_wp_cache("all"); ET_Core_PageResource::remove_static_resources("all", "all");' +``` + +`scope`, `layout_type` und `module_width` legt Divi beim nächsten Speichern in die Library wieder an. Gab es bis dahin eigene Library-Einträge, die diese Terme benutzen, die dritte Zeile weglassen. diff --git a/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html new file mode 100644 index 0000000..705efb4 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json new file mode 100644 index 0000000..c995a88 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--layout-ganze-seite.json @@ -0,0 +1,51 @@ +{ + "post": { + "ID": 1054, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Layout ganze Seite", + "post_name": "fixture-layout-ganze-seite", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:10", + "post_modified_gmt": "2026-09-26 20:56:10", + "content_file": "library--layout-ganze-seite.html", + "content_sha256": "2fcb9fb909d698b97581531ac69c5a3973647a57a4970d7a45a51fbe4ae24968" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "layout" + ] + }, + "terms": { + "layout_category": [ + { + "slug": "fixture", + "name": "Fixture" + } + ], + "layout_type": [ + { + "slug": "layout", + "name": "layout" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-abschnitt.html b/tests/Fixtures/divi/stage3/library--library-abschnitt.html new file mode 100644 index 0000000..3ad44eb --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-abschnitt.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-abschnitt.json b/tests/Fixtures/divi/stage3/library--library-abschnitt.json new file mode 100644 index 0000000..42539d3 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-abschnitt.json @@ -0,0 +1,45 @@ +{ + "post": { + "ID": 1055, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Abschnitt", + "post_name": "fixture-library-abschnitt", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:14", + "post_modified_gmt": "2026-09-26 20:56:14", + "content_file": "library--library-abschnitt.html", + "content_sha256": "c26526fccd9a1dac416a91ddd1459059858783478921744698b77fe83dcb7e94" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "section" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "section", + "name": "section" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-modul.html b/tests/Fixtures/divi/stage3/library--library-modul.html new file mode 100644 index 0000000..a627cf5 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-modul.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-modul.json b/tests/Fixtures/divi/stage3/library--library-modul.json new file mode 100644 index 0000000..7c51c2b --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-modul.json @@ -0,0 +1,54 @@ +{ + "post": { + "ID": 1057, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Modul", + "post_name": "fixture-library-modul", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:21", + "post_modified_gmt": "2026-09-26 20:56:21", + "content_file": "library--library-modul.html", + "content_sha256": "fd36cf6168b7492d26601fb4ed11accecc30fe1f8b7ec5a4d4091fc40d210ba4" + }, + "meta": { + "_et_pb_module_type": [ + "divi/blurb" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "module" + ] + }, + "terms": { + "layout_tag": [ + { + "slug": "fixture", + "name": "fixture" + } + ], + "layout_type": [ + { + "slug": "module", + "name": "module" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library--library-zeile.html b/tests/Fixtures/divi/stage3/library--library-zeile.html new file mode 100644 index 0000000..34a05dc --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-zeile.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library--library-zeile.json b/tests/Fixtures/divi/stage3/library--library-zeile.json new file mode 100644 index 0000000..2ef95b8 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library--library-zeile.json @@ -0,0 +1,48 @@ +{ + "post": { + "ID": 1056, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Library-Zeile", + "post_name": "fixture-library-zeile", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:18", + "post_modified_gmt": "2026-09-26 20:56:18", + "content_file": "library--library-zeile.html", + "content_sha256": "0a47d5133a4ca10a5fd0ebb49908894a5c20a17798d5954e0897958ad4704dfe" + }, + "meta": { + "_et_pb_row_layout": [ + "[object Object]" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "row" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "row", + "name": "row" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "non_global", + "name": "non_global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html new file mode 100644 index 0000000..e9c5312 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json new file mode 100644 index 0000000..0419e1f --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globaler-abschnitt.json @@ -0,0 +1,45 @@ +{ + "post": { + "ID": 1058, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Globaler Abschnitt", + "post_name": "fixture-globaler-abschnitt", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:25", + "post_modified_gmt": "2026-09-26 20:56:25", + "content_file": "library-global--globaler-abschnitt.html", + "content_sha256": "dd1549b879730de4d937c84236bb8e548f50024e47e9910561f24ae0e91b13a6" + }, + "meta": { + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "section" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "section", + "name": "section" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "global", + "name": "global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/library-global--globales-modul.html b/tests/Fixtures/divi/stage3/library-global--globales-modul.html new file mode 100644 index 0000000..d6acb90 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globales-modul.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/library-global--globales-modul.json b/tests/Fixtures/divi/stage3/library-global--globales-modul.json new file mode 100644 index 0000000..8655bf7 --- /dev/null +++ b/tests/Fixtures/divi/stage3/library-global--globales-modul.json @@ -0,0 +1,51 @@ +{ + "post": { + "ID": 1059, + "post_type": "et_pb_layout", + "post_status": "publish", + "post_title": "[Fixture] Globales Modul", + "post_name": "fixture-globales-modul", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:56:29", + "post_modified_gmt": "2026-09-26 20:56:29", + "content_file": "library-global--globales-modul.html", + "content_sha256": "3fe0aa7eb9a0aad0e79cc23702c3082f7ff8fbbb80e35185f01ccfc751c1690b" + }, + "meta": { + "_et_pb_module_type": [ + "divi/text" + ], + "_et_pb_excluded_global_options": [ + "[]" + ], + "_et_pb_built_for_post_type": [ + "page" + ], + "_et_pb_template_type": [ + "module" + ] + }, + "terms": { + "layout_type": [ + { + "slug": "module", + "name": "module" + } + ], + "module_width": [ + { + "slug": "regular", + "name": "regular" + } + ], + "scope": [ + { + "slug": "global", + "name": "global" + } + ] + } +} diff --git a/tests/Fixtures/divi/stage3/manifest.json b/tests/Fixtures/divi/stage3/manifest.json new file mode 100644 index 0000000..cb38cc1 --- /dev/null +++ b/tests/Fixtures/divi/stage3/manifest.json @@ -0,0 +1,17 @@ +{ + "divi_version": "5.2.1", + "posts": { + "page--globale-instanzen": 1053, + "library--layout-ganze-seite": 1054, + "library--library-abschnitt": 1055, + "library--library-zeile": 1056, + "library--library-modul": 1057, + "library-global--globaler-abschnitt": 1058, + "library-global--globales-modul": 1059, + "tb-header--globaler-header": 1061, + "tb-body--body-mit-dynamic-content": 1062, + "tb-footer--footer": 1063, + "tb-template--vorlage-globale-instanzen": 1066, + "tb-template--default": 1065 + } +} diff --git a/tests/Fixtures/divi/stage3/page--globale-instanzen.html b/tests/Fixtures/divi/stage3/page--globale-instanzen.html new file mode 100644 index 0000000..9e32819 --- /dev/null +++ b/tests/Fixtures/divi/stage3/page--globale-instanzen.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/page--globale-instanzen.json b/tests/Fixtures/divi/stage3/page--globale-instanzen.json new file mode 100644 index 0000000..9e94d99 --- /dev/null +++ b/tests/Fixtures/divi/stage3/page--globale-instanzen.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1053, + "post_type": "page", + "post_status": "draft", + "post_title": "[Fixture] Globale Instanzen", + "post_name": "", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "0000-00-00 00:00:00", + "post_modified_gmt": "2026-09-26 20:56:33", + "content_file": "page--globale-instanzen.html", + "content_sha256": "e5691e17e77315c449c5bf536cb3944ea0bc0bee316c45aa26e3b04485253623" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1053\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Globale Instanzen" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"4d4f306d-8e36-4b8f-9a7a-076eb1b075ac\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html new file mode 100644 index 0000000..a9ae55d --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json new file mode 100644 index 0000000..b1f4afe --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-body--body-mit-dynamic-content.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1062, + "post_type": "et_body_layout", + "post_status": "publish", + "post_title": "[Fixture] Body mit Dynamic Content", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 21:00:07", + "content_file": "tb-body--body-mit-dynamic-content.html", + "content_sha256": "4f2830538ea84c8c490ee278be883f09d89816c9635c7571be294c8bdadce3a2" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1062\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Body mit Dynamic Content" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"b773e1de-72ba-4640-85d6-249ce9e4a82d\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-footer--footer.html b/tests/Fixtures/divi/stage3/tb-footer--footer.html new file mode 100644 index 0000000..dcd03c9 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-footer--footer.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-footer--footer.json b/tests/Fixtures/divi/stage3/tb-footer--footer.json new file mode 100644 index 0000000..5403923 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-footer--footer.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1063, + "post_type": "et_footer_layout", + "post_status": "publish", + "post_title": "[Fixture] Footer", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 21:00:21", + "content_file": "tb-footer--footer.html", + "content_sha256": "7e6f6744cbc55a903cf346328ab383e7ddda85292990cf93b49594fa6f3d7f53" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1063\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Footer" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"7f8e1cce-7f40-4ac6-bc6d-239e0050ed4a\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-header--globaler-header.html b/tests/Fixtures/divi/stage3/tb-header--globaler-header.html new file mode 100644 index 0000000..d93ffb6 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-header--globaler-header.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/divi/stage3/tb-header--globaler-header.json b/tests/Fixtures/divi/stage3/tb-header--globaler-header.json new file mode 100644 index 0000000..e16320b --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-header--globaler-header.json @@ -0,0 +1,71 @@ +{ + "post": { + "ID": 1061, + "post_type": "et_header_layout", + "post_status": "publish", + "post_title": "[Fixture] Globaler Header", + "post_name": "theme-builder-layout", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:33", + "post_modified_gmt": "2026-09-26 20:59:52", + "content_file": "tb-header--globaler-header.html", + "content_sha256": "aa7c71a22cd8d7b3ecb7f6fbcbeaff289322b3797db3f3aac87763d29bb4b851" + }, + "meta": { + "_et_pb_use_builder": [ + "on" + ], + "_et_pb_show_page_creation": [ + "off" + ], + "_et_pb_ab_current_shortcode": [ + "[et_pb_split_track id=\"1061\" /]" + ], + "_et_pb_content_area_background_color": [ + "" + ], + "_et_pb_custom_css": [ + "" + ], + "_et_pb_enable_shortcode_tracking": [ + "off" + ], + "_et_pb_gutter_width": [ + "3" + ], + "_et_pb_page_z_index": [ + "auto" + ], + "_thumbnail_id": [ + "" + ], + "_et_pb_section_background_color": [ + "" + ], + "_et_pb_post_settings_title": [ + "[Fixture] Globaler Header" + ], + "_et_pb_post_settings_excerpt": [ + "" + ], + "_et_pb_first_image": [ + "" + ], + "_et_pb_truncate_post": [ + "" + ], + "_et_pb_truncate_post_date": [ + "" + ], + "_divi_off_canvas_data": [ + "a:2:{s:14:\"activeCanvasId\";s:36:\"b20ac30f-aeb9-4b8a-863f-44d36b514202\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}" + ], + "_et_pb_use_divi_5": [ + "on" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-template--default.json b/tests/Fixtures/divi/stage3/tb-template--default.json new file mode 100644 index 0000000..cc5e986 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-template--default.json @@ -0,0 +1,47 @@ +{ + "post": { + "ID": 1065, + "post_type": "et_template", + "post_status": "publish", + "post_title": "Standard-Website-Template", + "post_name": "standard-website-template", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 20:58:34", + "content_file": null, + "content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "meta": { + "_et_autogenerated_title": [ + "0" + ], + "_et_default": [ + "1" + ], + "_et_enabled": [ + "0" + ], + "_et_header_layout_id": [ + "1061" + ], + "_et_header_layout_enabled": [ + "1" + ], + "_et_body_layout_id": [ + "0" + ], + "_et_body_layout_enabled": [ + "1" + ], + "_et_footer_layout_id": [ + "0" + ], + "_et_footer_layout_enabled": [ + "1" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json b/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json new file mode 100644 index 0000000..6ddb0d6 --- /dev/null +++ b/tests/Fixtures/divi/stage3/tb-template--vorlage-globale-instanzen.json @@ -0,0 +1,50 @@ +{ + "post": { + "ID": 1066, + "post_type": "et_template", + "post_status": "publish", + "post_title": "[Fixture] Vorlage Globale Instanzen", + "post_name": "fixture-vorlage-globale-instanzen", + "post_author": 1, + "post_parent": 0, + "menu_order": 0, + "post_excerpt": "", + "post_date_gmt": "2026-09-26 20:58:34", + "post_modified_gmt": "2026-09-26 20:58:34", + "content_file": null, + "content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "meta": { + "_et_autogenerated_title": [ + "0" + ], + "_et_default": [ + "0" + ], + "_et_enabled": [ + "0" + ], + "_et_header_layout_id": [ + "1061" + ], + "_et_header_layout_enabled": [ + "1" + ], + "_et_body_layout_id": [ + "1062" + ], + "_et_body_layout_enabled": [ + "1" + ], + "_et_footer_layout_id": [ + "1063" + ], + "_et_footer_layout_enabled": [ + "1" + ], + "_et_use_on": [ + "singular:post_type:page:id:1053" + ] + }, + "terms": {} +} diff --git a/tests/Fixtures/divi/stage3/theme-builder.json b/tests/Fixtures/divi/stage3/theme-builder.json new file mode 100644 index 0000000..2e76296 --- /dev/null +++ b/tests/Fixtures/divi/stage3/theme-builder.json @@ -0,0 +1,20 @@ +{ + "theme_builder_post": { + "ID": 1064, + "post_status": "publish", + "post_title": "Theme Builder", + "meta": { + "_et_template": [ + "1065", + "1066" + ] + } + }, + "template_ids_in_order": [ + 1065, + 1066 + ], + "draft_theme_builder_posts": [], + "options_et_tb": {}, + "marked_as_unused": [] +} diff --git a/tests/Integration/divi-export-stage3-fixtures.php b/tests/Integration/divi-export-stage3-fixtures.php new file mode 100644 index 0000000..c4b2939 --- /dev/null +++ b/tests/Integration/divi-export-stage3-fixtures.php @@ -0,0 +1,185 @@ +.json per post (post row, all meta, + * all term relationships), the raw post_content byte-exact as .html, the live Theme + * Builder post with its template list and the et_tb_* options as theme-builder.json, and a + * manifest.json (role → post ID). How the fixtures were created: tests/Fixtures/divi/stage3/README.md. + * + * Usage (DDEV, WordPress root): + * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php + * + * Only posts titled "[Fixture] …" are exported, plus the Default Website Template when its + * layouts are fixture layouts. Read-only. Fixtures are committed – no personal data. + * No `declare(strict_types=1);` – wp eval-file runs the file through eval(). + */ + +defined('WP_CLI') && WP_CLI || exit; + +$lit_prefix = '[Fixture] '; +$lit_dir = dirname(__DIR__) . '/Fixtures/divi/stage3'; +$lit_skip_meta = ['_edit_lock', '_edit_last']; + +/** Role name for a fixture post, derived from post type, title and scope. */ +$lit_role = static function (WP_Post $post, array $terms): string { + $slug = sanitize_title(preg_replace('/^\[Fixture\]\s*/', '', $post->post_title)); + $prefix = [ + 'page' => 'page', + 'et_pb_layout' => in_array('global', array_column($terms['scope'] ?? [], 'slug'), true) + ? 'library-global' + : 'library', + 'et_template' => 'tb-template', + 'et_header_layout' => 'tb-header', + 'et_body_layout' => 'tb-body', + 'et_footer_layout' => 'tb-footer', + ][$post->post_type] ?? $post->post_type; + + return $prefix . '--' . $slug; +}; + +/** All term relationships by taxonomy – read from the tables, some Divi taxonomies are not registered under WP-CLI. */ +$lit_terms = static function (int $postId): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT tt.taxonomy, t.slug, t.name FROM {$wpdb->term_relationships} tr + JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id + JOIN {$wpdb->terms} t ON t.term_id = tt.term_id + WHERE tr.object_id = %d ORDER BY tt.taxonomy, t.slug", + $postId + )); + $terms = []; + foreach ($rows as $row) { + $terms[$row->taxonomy][] = ['slug' => $row->slug, 'name' => $row->name]; + } + + return $terms; +}; + +/** Raw meta rows (value as stored, multiple values kept in order). */ +$lit_meta = static function (int $postId) use ($lit_skip_meta): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d ORDER BY meta_id", + $postId + )); + $meta = []; + foreach ($rows as $row) { + if (! in_array($row->meta_key, $lit_skip_meta, true)) { + $meta[$row->meta_key][] = $row->meta_value; + } + } + + return $meta; +}; + +$lit_write = static function (string $file, string $data): void { + if (file_put_contents($file, $data) === false) { + WP_CLI::error('Cannot write ' . $file); + } +}; + +$lit_json = static fn (array $data): string => wp_json_encode( + $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE +) . "\n"; + +global $wpdb; +$lit_types = ['page', 'et_pb_layout', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout']; +$lit_ids = array_map('intval', $wpdb->get_col($wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type IN (" . implode(', ', array_fill(0, count($lit_types), '%s')) . ") + AND post_status NOT IN ('inherit', 'auto-draft') + AND post_title LIKE %s + ORDER BY ID", + ...[...$lit_types, $wpdb->esc_like($lit_prefix) . '%'] +))); + +// The live Theme Builder post and the Default Website Template, when it points to fixture layouts. +$lit_tb_id = (int) $wpdb->get_var( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'publish' ORDER BY ID LIMIT 1" +); +$lit_tb_templates = $lit_tb_id ? array_map('intval', get_post_meta($lit_tb_id, '_et_template', false)) : []; +foreach ($lit_tb_templates as $templateId) { + if (get_post_meta($templateId, '_et_default', true) !== '1') { + continue; + } + foreach (['header', 'body', 'footer'] as $area) { + $layoutId = (int) get_post_meta($templateId, "_et_{$area}_layout_id", true); + if ($layoutId > 0 && in_array($layoutId, $lit_ids, true) && ! in_array($templateId, $lit_ids, true)) { + $lit_ids[] = $templateId; + } + } +} + +if ($lit_ids === []) { + WP_CLI::error('No "[Fixture] " posts found – create them first (see tests/Fixtures/divi/stage3/README.md).'); +} + +if (! is_dir($lit_dir) && ! wp_mkdir_p($lit_dir)) { + WP_CLI::error('Cannot create ' . $lit_dir); +} +foreach (glob($lit_dir . '/*.{json,html}', GLOB_BRACE) ?: [] as $old) { + unlink($old); +} + +$lit_manifest = ['divi_version' => wp_get_theme('Divi')->get('Version'), 'posts' => []]; +foreach ($lit_ids as $postId) { + $post = get_post($postId); + $terms = $lit_terms($postId); + $role = $post->post_type === 'et_template' && get_post_meta($postId, '_et_default', true) === '1' + ? 'tb-template--default' + : $lit_role($post, $terms); + + $record = [ + 'post' => [ + 'ID' => $post->ID, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'post_author' => (int) $post->post_author, + 'post_parent' => $post->post_parent, + 'menu_order' => $post->menu_order, + 'post_excerpt' => $post->post_excerpt, + 'post_date_gmt' => $post->post_date_gmt, + 'post_modified_gmt' => $post->post_modified_gmt, + 'content_file' => $post->post_content === '' ? null : $role . '.html', + 'content_sha256' => hash('sha256', $post->post_content), + ], + 'meta' => (object) $lit_meta($postId), + 'terms' => (object) $terms, + ]; + $lit_write($lit_dir . '/' . $role . '.json', $lit_json($record)); + if ($post->post_content !== '') { + $lit_write($lit_dir . '/' . $role . '.html', $post->post_content); + } + $lit_manifest['posts'][$role] = $postId; +} + +$lit_options = []; +$lit_option_rows = $wpdb->get_results( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'et\\_tb\\_%' ORDER BY option_name" +); +foreach ($lit_option_rows as $row) { + $lit_options[$row->option_name] = $row->option_value; +} +$lit_write($lit_dir . '/theme-builder.json', $lit_json([ + 'theme_builder_post' => $lit_tb_id ? [ + 'ID' => $lit_tb_id, + 'post_status' => get_post_status($lit_tb_id), + 'post_title' => get_the_title($lit_tb_id), + 'meta' => (object) $lit_meta($lit_tb_id), + ] : null, + 'template_ids_in_order' => $lit_tb_templates, + 'draft_theme_builder_posts' => array_map('intval', $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'auto-draft'" + )), + 'options_et_tb' => (object) $lit_options, + 'marked_as_unused' => array_map('intval', $wpdb->get_col( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_et_theme_builder_marked_as_unused'" + )), +])); +$lit_write($lit_dir . '/manifest.json', $lit_json($lit_manifest)); + +WP_CLI::success(sprintf('%d posts exported to %s', count($lit_manifest['posts']), $lit_dir)); From 30788f2841944fb386d04177c3e7d5dbcba1f7cf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 23:05:34 +0200 Subject: [PATCH 03/47] docs: Etappe 3 decisions draft with user answers Co-Authored-By: Claude Opus 5.5 (1M context) --- ...6-09-27-etappe-3-entscheidungen-entwurf.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md diff --git a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md new file mode 100644 index 0000000..7029bb0 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md @@ -0,0 +1,162 @@ +# Etappe 3 – Entscheidungen der Planung (Entwurf) + +Vorbereitung von Etappe 3 („Library & Theme Builder“), 2026-09-26/27. Grundlage: Spec §3, §4.3, §5, §7, §10, §12, §15; Entscheidungen Etappe 2 (D1–D12); Rulings 2b (S1–S52), Praxistest und Security-Audit 2b. Quelle: Divi 5.2.1 (`wp-content/themes/Divi`, `style.css:4`), Pfade relativ zu `themes/Divi/includes/`. Live geprüft nur lesend (DDEV, `wp eval-file`). Entwurf – noch nicht vom Controller freigegeben. Kosten, falls falsch, jeweils am Ende. + +## 1. Verifizierte Fakten + +### 1.1 Divi Library (`et_pb_layout`) + +- F1: Post-Typ `et_pb_layout` (`ET_BUILDER_LAYOUT_POST_TYPE`, `builder/post/type/Layout.php:4`), `capability_type post`, `map_meta_cap`, `show_ui true`, `show_in_rest false`, unterstützt `revisions` (`Layout.php:79`). Taxonomien `layout_category`, `layout_tag`, `layout_pack`, `layout_type`, `module_width`, `scope`, `layout_location` (`Layout.php:83-90`). Live: `assign_terms` = `edit_posts`, verwalten = `manage_categories`; `layout_location` ist lokal nicht registriert. +- F2: Anlegen (Divi 4-Weg, von Divi 5 weiter benutzt): `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) setzt Terme `scope` (`global`/`not_global`), `layout_type` (`layout`/`section`/`row`/`module`), `module_width` (`regular`/`fullwidth`), Kategorien/Tags und Meta `_et_pb_row_layout` (Zeilen), `_et_pb_module_type` (Module), `_et_pb_excluded_global_options = "[]"` (globale Module), `_et_pb_built_for_post_type`, `_et_pb_template_type`; `et_pb_create_layout()` (`:738-800`) speichert immer mit `post_status publish` über `wp_insert_post` (also mit kses). Im REST-/CLI-Kontext ist `et_pb_submit_layout` nicht geladen (live: `function_exists` = false; geladen nur über `builder/shortcode-framework.php:6`). +- F3: Divi 5 legt Library-Einträge über die Builder-Route `POST divi/v1/divi-library/create-item` an (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:743-749`, `DiviLibraryController::create_item` `DiviLibraryController.php:606-757`): D4-Konvertierung, Preset-/Farb-/Variablen-Import, dann `et_pb_submit_layout` (`:715`). Recht: `edit_posts` + `et_pb_is_allowed('divi_library')` + `('save_library')` (`:958-966`). Inhalt wird vorher mit `SavingUtility::prepare_content_for_db` bereinigt (`:980`; `VisualBuilder/Saving/SavingUtility.php:230-254`: ohne `unfiltered_html` wird die Seite komplett geparst und neu serialisiert). Der Versuch, danach `_et_pb_use_divi_5` zu setzen, liest `$new_layout_meta['layout_id']` aus einem JSON-String (`:715-720`) – auf dieses Flag ist bei Library-Einträgen kein Verlass. +- F4: Library-Einträge speichern ihren Inhalt in `post_content` in einem `divi/placeholder`-Wrapper (`DiviLibraryController.php:2395, 2461-2466`). +- F5: „Global“ = Term `scope=global`; Rückfall für importierte D4-Module: `_et_pb_module_type` und `_et_pb_excluded_global_options` gesetzt (`builder-5/server/Packages/GlobalLayout/GlobalLayout.php:34-61`, `GlobalLayout::is_global_layout_template()`). +- F6: Instanz auf einer Seite = Platzhalterblock `divi/global-layout` mit `{"globalModule":"","blockName":"divi/","localAttrs":{…}}` und den Kindern als `innerBlocks` (Schnappschuss). Belegt durch die D4→D5-Konvertierung (`builder-5/server/Packages/Conversion/Conversion.php:1454-1479`) und die Serialisierung des Builders (`builder-5/visual-builder/build/serialized-post.js`, Filter `divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested`: alle Attribute außer `globalModule`, `localAttrsMap`, `localChildren`, `globalParent`, `blockName`, `globalModuleTemplate` wandern nach `localAttrs`). Kinder innerhalb einer globalen Vorlage tragen `globalParent` (`Conversion.php:1491-1494`). Modul-Metadaten: `divi/global-layout` hat keine Attribute (`builder-5/server/_all_modules_metadata.php:18550-18557`). +- F7: **Ausgabe per Verweis.** Beim Rendern ersetzt `BlockParser` jeden `divi/global-layout` durch den aktuellen Inhalt des Library-Posts (`builder-5/server/FrontEnd/BlockParser/BlockParser.php:838-884`; `BlockParserStore::get_global_layout_content`, `BlockParserStore.php:1113-1330`). Aus dem Schnappschuss `localAttrs` werden nur die Attribute übernommen, die die Vorlage in `localAttrsMap` als „nicht synchronisiert“ führt (`apply_local_attrs_filtering`, `BlockParserStore.php:348`); die Kinder aus der Seite nur, wenn die Vorlage `localChildren: true` hat (`:1307-1311`). Die Seitenkopie ist also ein Schnappschuss, maßgeblich ist der Library-Post. +- F8: **Keine Expansion bei REST-POST.** `BlockParser` überspringt die Expansion bei `MigrationContext`, beim Speichern und bei jedem REST-Request mit POST/PUT/PATCH (`BlockParser.php:827-831`, `_is_rest_update_request` `:690-701`). Unsere MCP-Aufrufe sind REST-POST – `lit/page-render-summary` (D5, `the_content` im Prozess) sieht globale Module daher **nicht**. +- F9: Bearbeitet man eine globale Instanz im Visual Builder, speichert der Builder sofort den Library-Post über `POST divi/v1/divi-library/save` (`visual-builder/build/global-layouts.js`; `DiviLibraryController::save` `:1439-1447` = `wp_update_post(wp_slash(…))`, Inhalt vorher `prepare_content_for_db`). Recht: `edit_posts` + `et_pb_is_allowed('edit_global_library')` (`:1494-1500`) – **ohne** `edit_post` auf den Eintrag. +- F10: Verschachtelte globale Module sind verboten: beim Öffnen einer globalen Vorlage im Builder werden innere `divi/global-layout` „entglobalisiert“ (`VisualBuilder/SettingsData/SettingsDataCallbacks.php:739-743`, `Framework/Portability/PortabilityPost.php:3521-3605` `maybe_deglobalize_nested_global_modules`: Platzhalter → `blockName` mit `localAttrs` als Attribute, `globalParent` entfernt). Divi selbst liest `globalModule` aus Markup per String-Scan, weil `parse_blocks()` damit nicht zuverlässig umgeht (`PortabilityPost.php:3510`, `BlockParserStore.php:1137-1184`, `FrontEnd/Assets/DetectFeature.php:349-359`). +- F11: Library-REST-Routen (`RESTRegistration.php:671-775`): `divi-library` (Liste), `/item`, `/update-terms`, `/update-item`, `/convert-item`, `/split-item`, `/load`, `/create-item`, `/save`, `/upload-image`, `/item-location` (löscht trotz Namen Posts, `DiviLibraryController.php:1197-1221`). Alle nonce-geschützt, nur für den Builder. +- F12: Rechte im Divi-Rollen-Editor: `et_pb_is_allowed()` (`builder/core.php:1128-1162`); Library-Admin braucht `divi_library` + `save_library` (`builder/functions.php:340-346`), globale Einträge zusätzlich `edit_global_library` (`functions.php:366-372`; im Builder `core.php:4248-4262`). `theme_builder` und `read_dynamic_content_custom_fields` sind „gefährlich“: für andere Rollen als Administrator standardmäßig **aus** (`core.php:1138-1156`), Multisite-Super-Admins immer an. Live: keine Rollen-Einstellungen gespeichert (`et_pb_role_settings` leer) → Standardwerte. +- F13: Live-Bestand: 0 `et_pb_layout`, 0 `et_template`, 0 `et_theme_builder`, 0 TB-Layouts, kein Inhalt mit `globalModule` (Probe `s1.php`). **Echte Library-/TB-Daten fehlen vollständig.** + +### 1.2 Theme Builder + +- F14: Post-Typen `et_theme_builder`, `et_template`, `et_header_layout`, `et_body_layout`, `et_footer_layout` (`builder/frontend-builder/theme-builder/theme-builder.php:7-23, 89-252`), alle `capability_type post`, `show_ui false`, `show_in_rest false`; Layouts unterstützen `revisions` (`:184, 216, 248`), Vorlagen nicht (`:152`). +- F15: Live- und Entwurfsstand: `et_theme_builder_get_theme_builder_post_id($live)` sucht den TB-Post mit Status `publish` (live) bzw. `auto-draft` (Entwurf des TB-Editors), ohne Meta `_et_library_theme_builder` (`theme-builder.php:378-413`). Die Vorlagen hängen als mehrfaches Meta `_et_template` am TB-Post, mit Sicherung in der Option `et_tb_templates_backup_` während des Speicherns (`:424-441`; `api.php:223-243`). +- F16: Vorlage (`et_template`) = Meta `_et_default`, `_et_enabled`, `_et_autogenerated_title`, `_et_{header,body,footer}_layout_id`, `_et_{…}_layout_enabled`, `_et_{…}_layout_global`, `_et_library_item_id`, mehrfach `_et_use_on`/`_et_exclude_from` (`theme-builder.php:471-521`). `override` = Layout-ID ≠ 0 oder Bereich deaktiviert (`:502`). +- F17: `et_theme_builder_store_template($tb_id, $template, $allow_default)` (`theme-builder.php:1463-1586`): `wp_die()` ohne `edit_others_posts` (`:1464-1466`); neue Vorlagen sofort `publish` (`:1515-1521`); Layout-IDs nur übernommen, wenn der passende Typ **und `publish`** (`:1486-1496`); Bedingungen `sanitize_text_field`, dedupliziert (`:1565-1583`). Die Funktion hängt die Vorlage **nicht** an den TB-Post – das macht nur `et_theme_builder_api_save` (`api.php:223-243`). +- F18: `et_theme_builder_insert_layout($options)` (`theme-builder.php:1643-1667`): `wp_die()` ohne `edit_others_posts`, `wp_insert_post` mit `post_status publish` (kses!), Term `layout_type=layout`, `et_builder_enable_for_post()` (setzt `_et_pb_use_builder`, `core.php:4438-4449`). +- F19: Divis TB-Speichern läuft über admin-ajax (`wp_ajax_et_theme_builder_api_save`, `api.php:160-273`: `et_builder_security_check('theme_builder', 'edit_others_posts', …)`), ersetzt die gesamte `_et_template`-Liste des TB-Posts (`:233-240`), trasht beim Live-Speichern den Entwurf und markiert unreferenzierte Vorlagen/Layouts mit `_et_theme_builder_marked_as_unused`; nach 7 Tagen werden sie gelöscht (max. 50 je Lauf, `theme-builder.php:591-678`). Danach `et_theme_builder_clear_wp_cache('all')` und `ET_Core_PageResource::remove_static_resources('all','all')` (`api.php:249-254`). +- F20: `api.php` wird nur bei `is_admin() || wp_doing_ajax()` geladen (`theme-builder.php:50-61`); im REST-Kontext fehlen die `et_theme_builder_api_*`-Funktionen. Vorhanden (live geprüft): `et_theme_builder_insert_layout`, `et_theme_builder_store_template`, `et_theme_builder_get_theme_builder_templates`, `et_theme_builder_get_template_settings_options`, `et_theme_builder_get_flat_template_settings_options`, `et_theme_builder_clear_wp_cache`. Divi 5 hat keine eigenen TB-REST-Routen (nur `ThemeBuilder/Layout.php`, `ThemeBuilder.php` für die Ausgabe). +- F21: Bedingungs-IDs mit Trenner `:` (`theme-builder.php:28`): `singular:post_type::all` (`:703-710`), `archive:post_type:` (nur `post` oder `has_archive`, `:724-734`), `singular:post_type::id:` (`:805-813`), `singular:post_type::children:id:` (`:822-829`), `homepage` (`:717`), `search`, `404` (`:1030-1039`), Archiv-, Taxonomie-, Autor-, Datumsvarianten (`:873-987`). Jede hat einen `validate`-Callback (`template-setting-validations.php:13-325`); `et_theme_builder_get_flat_template_settings_options()` (`:1143`) liefert die flache Liste. +- F22: Die Layouts des Theme Builders werden bei der Ausgabe live gelesen (`et_theme_builder_get_template_layouts`, `theme-builder.php:1295-1406`); Änderungen an einem aktiven Layout sind sofort öffentlich. Divi-Module mit `appearance.themeBuilderLayouts` (z. B. `divi/post-content`, `_all_modules_metadata.php:25503`; `fullwidth-post-content` `:14455`) werden in anderen Bereichen nicht gerendert (`Packages/ModuleLibrary/ModuleRegistration.php:509-527`). +- F23: Divi-Recht für TB-Layouts im Builder: `et_pb_is_allowed('theme_builder')` (`functions.php:2353`) plus die WP-Rechte; TB-Admin-AJAX zusätzlich `edit_others_posts`. + +### 1.3 Dynamic Content, Pods, Loop + +- F24: `DynamicContentOptions::get_options($post_id, $context)` sammelt alles über den Filter `divi_module_dynamic_content_options` (`builder-5/server/Packages/Module/Layout/Components/DynamicContent/DynamicContentOptions.php:85-133`). Live für Seite 831: 57 Optionen, davon 34 `loop_*`, `post_meta_key` (Felder `before|after|select_meta_key|meta_key|date_format|custom_date_format|enable_html`) und je Post-Typ `post_link_url_` mit Feld `post_id`. +- F25: Meta-Schlüssel-Liste: „meistgenutzte“ Meta-Schlüssel der ganzen Website plus ACF-Felder – nur mit `read_dynamic_content_custom_fields` oder im Kontext `display` (`DynamicContentOptionPostMetaKey.php:80-140`). Pods kennt Divi nicht. +- F26: Ausgabe von `custom_meta_`: Wert über `get_post_meta`/ACF, Filter `divi_module_dynamic_content_resolved_custom_meta_value` (`DynamicContentOptionPostMetaKey.php:404`), dann `esc_html` ohne `enable_html` (`:452-457`). Arrays von Strings werden mit „, “ verbunden; Beziehungs-/Dateifelder ohne ACF bleiben rohe IDs. +- F27: Interner Link als natives Dynamic Content: `post_link_url_` mit `settings.post_id`, Ausgabe `esc_url(get_permalink($id))` zur Renderzeit (`DynamicContentOptionCustomPostLinkUrl.php:34, 186-217`) – entwurfssicher, nach dem Veröffentlichen automatisch die schöne URL. +- F28: Loop Builder: `module.advanced.loop.desktop.value.{enable, queryType, …}` (`Packages/Module/Options/Loop/LoopUtils.php:135-145`), Werte im Loop über `loop_*`-Optionen; die Expansion läuft nur beim Rendern (`BlockParser.php:895-905`). + +### 1.4 Stand im Plugin + +- F29: `ContentSchema::EXCLUDED` schließt `et_pb_layout`, `et_theme_builder`, `et_template` und die drei TB-Layout-Typen aus (`src/Integrations/WordPress/Schema/ContentSchema.php:19-24`); `PostGuard::editable` liefert dafür `not_found`. Die Divi-Werkzeuge erreichen Library und TB heute nicht. +- F30: Opak sind Blöcke mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen (`src/Integrations/Divi/BlockTree/Parser.php:56`); `Block::setAttrs` wirft dort (`Block.php:91-96`). +- F31: Parser-Kosten (live, 8 Divi-Posts, 28 KB, 20 Durchläufe): Parse + NodeIndex ≈ 2 ms je 100 KB Inhalt (Probe `s3.php`). +- F32: Divi 5.2.1 registriert **keine** WordPress Abilities (`grep wp_register_ability|wp_abilities_api_init` im Theme: 0 Treffer) und enthält kein MCP. + +## 2. Empfohlene Entscheidungen + +- E1 – Ein Zugang für alle Divi-Posts. Die bestehenden Divi-Werkzeuge (`lit/divi-outline`, `-node-get`, `-insert`, `-update`, `-move`, `-delete`, `-duplicate-node`) akzeptieren zusätzlich IDs von `et_pb_layout` und `et_header/body/footer_layout` (Spec §5.1). Umsetzung: eine `DiviTarget`-Richtlinie neben `PostGuard` (Typ, Recht, Status-Regel je Typ), die `DiviPageLoader` verwendet; `ContentSchema::EXCLUDED` bleibt (die Content-Tools sehen diese Typen weiter nicht). – Grund: keine zweite Werkzeugfamilie, gleiche Pipeline (D7, S39). – Kosten, falls falsch: Rechteprüfung pro Typ an einer Stelle falsch → Zugriff auf Library/TB ohne Divi-Recht; abgefangen durch eigene Tests je Typ. +- E2 – Globale Module werden im Library-Post bearbeitet, nie in der Seitenkopie. Auf Seiten bleiben `divi/global-layout`-Knoten opak (D3); `lit/divi-node-get`/`-outline` liefern für sie `global_id`, `block_name` und einen Hinweis „mit post_id= bearbeiten“ (S19-Teil 2). Schreiben in den Library-Post wirkt auf alle Instanzen (F7); die Antwort nennt die betroffenen Seiten (Verwendungssuche über `"globalModule":""` in `post_content`, wie Divi F10) und verlangt bei mehr als einer veröffentlichten Instanz `confirm: true`. `localAttrs`/Kinder-Schnappschüsse in Seiten werden nie angefasst; das Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz ist nicht Teil von Etappe 3. – Grund: Divis Ausgabe ist per Verweis (F7), der Builder macht es genauso (F9). – Kosten: Instanz-spezifische Abweichungen nur im Builder. +- E3 – Rechte (strenger als Divi, nie lockerer). Library lesen: `edit_posts` + `divi_library`; speichern/anlegen: + `save_library`; einfügen aus der Library: + `add_library`; globalen Eintrag bearbeiten: + `edit_global_library` **und** `edit_post` auf den Eintrag (Divi prüft das nicht, F9). Theme Builder lesen und schreiben: `edit_others_posts` + `et_pb_is_allowed('theme_builder')` (F19, F23; Spec §4.5 um die Divi-Rolle ergänzt). Dynamic-Content-Meta-Liste nur mit `read_dynamic_content_custom_fields` (F25). Weil `et_theme_builder_store_template`/`insert_layout` bei fehlendem Recht `wp_die()` aufrufen (F17, F18), prüft das Plugin vorher selbst. `et_pb_is_allowed` steckt in einem Adapter (E15). – Kosten: ein Redakteur mit WP-Rechten, aber ohne Divi-Rolle, bekommt `forbidden` (gewollt, wie im Builder). +- E4 – Library-Einträge legt das Plugin selbst an (kein `et_pb_submit_layout`, keine Builder-Route): `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Library-Entwürfe, F2), Terme/Meta wie F2, danach Inhalt über `DiviSaver::saveNew` im kses-Fenster (D7, D11-Nachtrag), eingewickelt in `divi/placeholder` (F4), Flags wie `DiviFlags`. Ist ein Schritt fehlerhaft, wird der Entwurf endgültig gelöscht (wie `lit/content-create`). – Grund: `et_pb_submit_layout` ist im REST-Kontext nicht geladen und speichert mit kses (F2); die Builder-Route braucht Nonce und importiert Presets/Farben (F3). – Kosten: Divi ändert Terme/Meta → Einträge fehlen in der Library-Ansicht; abgesichert durch Fixture-Vergleich mit echten Builder-Einträgen (E13) und Kompatibilitätsprüfung (E15). +- E5 – `lit/library-insert` als Kopie oder globale Instanz. Kopie: Inhalt ohne `divi/placeholder`, verschachtelte globale Platzhalter entglobalisiert wie Divi (F10), eingefügt wie `lit/divi-insert` (StructureRules, AdminLabel). Globale Instanz: das Plugin erzeugt `` ohne `localAttrs` (voll synchronisiert). Prüfungen: Eintrag existiert, `publish`, `scope=global`, `read_post`, Wurzeltyp passt an die Zielposition, kein globales Modul in einem globalen Library-Post (F10). – Kosten: falls der Builder leere `localAttrs`/fehlende `innerBlocks` anders erwartet, sieht der Mensch im Builder evtl. eine andere Darstellung – vor dem Bau mit einer Builder-Fixture verifizieren (E13). +- E6 – Werte aus gespeicherten Inhalten (Library → Seite, Seite → Seite, Seite → Library) gelten als „gespeichert, nicht vom Client“ wie bei `lit/divi-duplicate-node`, werden aber beim Wechsel des Posts erneut geprüft: für Benutzer ohne `unfiltered_html` werden Knoten mit Builder-only-Feldern (S7/S26: `*.decoration.attributes`, `*.advanced.html`, `*.css.*`) und Code-Modul abgelehnt („nur im Divi-Builder“). Alles andere geht unverändert durch den BlockTree (byte-identisch). – Grund: Audit M1 – der Weg über eine andere Post-ID darf die Builder-only-Sperre nicht umgehen; Autor und damit Divis `before/after`-Filterung ändern sich mit dem Ziel-Post (R2-Befund 1). – Kosten: Redakteure können admin-gebaute Abschnitte mit eigenem CSS nicht kopieren. +- E7 – Theme Builder: Vorlagen und Layouts über Divis vorhandene Funktionen (`et_theme_builder_insert_layout` mit leerem Inhalt, Inhalt danach über `DiviSaver::saveNew`; `et_theme_builder_store_template`), Zuordnung zum Live-TB-Post selbst (`add_post_meta($live_id, '_et_template', …)`, gleiche Reihenfolge wie F19 inkl. Backup-Option), danach Cache wie F19. Nie der Entwurfs-TB-Post, nie `et_theme_builder_trash_draft_and_unused_posts()`. Existiert ein TB-Entwurf (`auto-draft`, F15), wird abgelehnt mit Hinweis „Theme Builder ist in Bearbeitung – dort speichern oder verwerfen“ (sonst überschreibt ein späteres Speichern im TB-Editor die Zuordnung und die neue Vorlage wird nach 7 Tagen gelöscht, F19). Bedingungen nur aus `et_theme_builder_get_flat_template_settings_options()` plus deren `validate`-Callback, Post-IDs mit `read_post`. – Kosten: Divi ändert die TB-Speicherung → Vorlage fehlt im TB-Editor; Kompatibilitätsprüfung + E2E. +- E8 – Neue TB-Vorlagen werden **deaktiviert** angelegt (`_et_enabled=0`); aktivieren ist eine explizite Aktion in `lit/tb-assign` (`enabled: true`, `confirm: true`, Liste der betroffenen Bedingungen). Standardvorlage (`_et_default`) nie über MCP. – Grund: Spec §2 Prinzip 5 (Status bleibt, Veröffentlichen explizit); TB-Posts kennen keinen Entwurf (F17, F18, F22). – Kosten: ein Aufruf mehr. Offen als Frage F-1. +- E9 – Werkzeugliste Etappe 3 und Exposition. Direkt: nur `lit/library-insert` (Spec §4.3) → 22 direkte Tools, unter der Warnschwelle 25 des Tools-Tabs. Indirekt: `lit/library-list` (mit `id` → Detail; ersetzt `lit/library-get`), `lit/library-save`, `lit/divi-node-copy`, `lit/library-make-global`, `lit/library-find-similar`, `lit/divi-dynamic-sources`, `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`. Gruppen: Library und Theme Builder als eigene Gruppen („nur lesen“ wirkt wie bei Divi, Audit M2: `lit/library-insert` und `lit/divi-node-copy` folgen zusätzlich dem Modus von `lit/divi-insert`). Destruktiv-Hinweis: `lit/library-make-global` (ersetzt Knoten), `lit/tb-assign` (öffentliche Wirkung). – Kosten: `lit/library-get` aus der Spec entfällt als eigener Name (Spec §5.7 anpassen). +- E10 – `lit/library-make-global`: legt einen globalen Eintrag aus dem Knoten an (E4) und ersetzt den Knoten durch eine Instanz (E5), in einem Aufruf, alles oder nichts (Library-Post wird bei Fehlschlag der Seite gelöscht). „Gleichartige Vorkommen ersetzen“ nur für **inhaltsgleiche** Knoten (Inhalts-Fingerabdruck, E11), mit Probelauf (Standard) und `confirm: true` je Seite mit `tree_rev` (Muster D10). Strukturgleiche Knoten mit anderen Texten werden nur gemeldet, nie ersetzt (Spec §3.3 „fast gleich“ = später). – Kosten: Kunde muss ähnliche Abschnitte vorher angleichen. +- E11 – `lit/library-find-similar`: zwei Fingerabdrücke je Abschnitt/Zeile: Struktur (Blocknamen und Tiefe, Spalten-`flexType`, Preset-IDs) und Inhalt (kanonische Attribute ohne Admin-Label, Bytes der Kinder). Suche seitenweise mit `cursor`, Post-Typ-Filter, höchstens 200 Posts je Aufruf, nur Posts, die der Benutzer bearbeiten darf; kein Index/Meta (F31: ≈1 s Parse für 1000 Seiten à 50 KB, Datenbanklast dominiert). Die Schreibwarnung „ähnliche Abschnitte“ (Spec §5.5, D6) vergleicht nur mit Library-Einträgen (klein) und verweist sonst auf das Tool. – Kosten: auf sehr großen Websites mehrere Aufrufe; bei Bedarf später ein Cache nach `tree_rev`. +- E12 – Dynamic Content. `lit/divi-dynamic-sources(post_type)` liefert `DynamicContentOptions::get_options` für einen Beispiel-Post des Typs (sonst `0`), ohne `loop_*` (S36), dazu Pods-Felder des Typs aus `PodsFieldSource` mit Speicherart und Eignung (Text-ähnlich = `{{field:}}`; Beziehung/Datei = „zeigt IDs“, F26). Beim Schreiben in einen TB-Body-Layout gilt als Kontext-Post-Typ der Typ aus den Bedingungen der zugeordneten Vorlage (`singular:post_type::…`); ohne Zuordnung Warnung. Loop Builder bleibt lesbar/opak (Werte unverändert), wird nicht erzeugt. – Kosten: CPT-Archive mit Pods-Feldern in Schleifen erst später. +- E13 – Übernahmen aus Etappe 2: (a) `{{link:}}` → `post_link_url_` mit `settings.post_id` (F27), Prüfung `read_post`, kein Papierkorb; nur in URL-Feldern; (b) `lit/guide` mit `topic: "all"` (alle Themen in einem Aufruf, Größenlimit); (c) S19: `lit/divi-design-system` mit Filter (`section`: colors/variables/presets/fonts, `module` für Presets) und `global_id` in `node-get`; (d) `lit/page-render-summary` meldet globale Platzhalter als Hinweis „globales Modul – im Auszug nicht enthalten“ (F8) und erhält für Library-/TB-IDs keinen Zugang. – Kosten: gering. +- E14 – Reihenfolge und Aufteilung. **3a „Library“**: Fixtures (E16) → Übernahmen E13 → `DiviTarget`/Rechte (E1, E3) → `lit/library-list` → Divi-Werkzeuge auf Library-Posts inkl. globaler (E2) → `lit/library-save` (E4) → `lit/library-insert` (E5) → `lit/divi-node-copy` (E6) → `lit/divi-dynamic-sources` (E12) → Guides/Prompt `globale-module-vorschlagen`. **3b „Theme Builder & Konsolidierung“**: `lit/tb-list` → TB-Layouts in den Divi-Werkzeugen (inkl. `themeBuilderLayouts`-Regel F22 in StructureRules) → `lit/tb-create-template` (E7, E8) → `lit/tb-assign` → `lit/library-find-similar` + Warnung (E11) → `lit/library-make-global` (E10) → Guide `theme-builder`, Prompt `custom-post-type-vorlage`. Abschluss jeder Teiletappe wie 2b (Review je Task, Security-Audit, Praxistest). – Kosten: find-similar/make-global kommen spät; sie hängen an stabilen Library-Grundlagen. +- E15 – Divi-Zugriffe über Adapter mit Live-Kompatibilitätsprüfung (siehe Abschnitt 5): je Divi-Bereich eine Klasse (`DiviModules` und `DesignSystemReader` gibt es schon; neu `DiviLibraryStore`, `DiviThemeBuilder`, `DiviRoles`), die Funktions-/Klassen-Existenz und Signaturen prüft und bei Abweichung `unsupported` statt Fatal liefert; `lit/site-status` zeigt den Befund. – Kosten: etwas mehr Code; spart Ausfälle nach Divi-Updates. +- E16 – Teststrategie. (1) Unit: Platzhalter-Erkennung, Fingerabdrücke, Bedingungs-Validierung, Instanz-Markup, Entglobalisieren gegen Divis `maybe_deglobalize_nested_global_modules` (gleiche Eingaben, gleiches Ergebnis). (2) Fixtures aus echten Builder-Daten: Jan-Niclas baut vor Beginn in DDEV im Visual Builder nach Checkliste globales Modul, globale Zeile mit einem nicht synchronisierten Attribut, globaler Abschnitt, normaler Library-Abschnitt, je eine Seite mit Instanzen, eine TB-Vorlage „Person“ mit Header/Body/Footer und Bedingungen; ein neues Lese-Skript `tests/Integration/divi-export-library.php` exportiert `post_content`, Terme, Meta und TB-Meta als `builder-library-*.html` / `builder-tb-*.json` (wie D12). Roundtrip-Test läuft automatisch mit. (3) DDEV-Smoke `divi-library-smoke.sh` / `tb-smoke.sh` (mit Aufräumen). (4) Manuelle Abnahme: vom Plugin erzeugte Einträge/Instanzen/Vorlagen im Builder und TB-Editor öffnen und speichern. – Kosten: ohne Fixtures (F13) bleiben E4/E5/E7 Vermutung; daher Fixtures als erster Schritt und Bedingung für den Bau. +- E17 – Sicherheit neuer Schreibwege: jeder Weg nach `post_content` von Library- und TB-Posts geht durch Composer/AttrWriter/TreeEditor → `DiviSaver` (D7, S39); Titel, Terme und Meta über WordPress mit üblicher Filterung (`sanitize_text_field`, `wp_set_object_terms` mit bestehenden Term-IDs; neue Terme nur mit `manage_categories`); keine Builder-Route und kein `et_pb_create_layout` mit Client-Inhalt; TB-Bedingungen nur aus Divis Liste; Revision vor jeder Änderung (Library und TB-Layouts unterstützen Revisionen, F1/F14), Protokolleintrag je Post; nach Library-/TB-Änderungen `remove_static_resources('all','all',true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5). – Kosten: Review-Pflicht wie D7. + +## 3. Fragen an den Benutzer (nach Wichtigkeit) + +1. **Sollen neue Theme-Builder-Vorlagen sofort wirken?** Divi kennt für Vorlagen keinen Entwurf. + - (a) Deaktiviert anlegen, Aktivieren nur ausdrücklich mit `lit/tb-assign` und Bestätigung. **Empfehlung.** + - (b) Sofort aktiv, wie im TB-Editor. + - (c) Aktiv nur auf Bedingungen für einzelne IDs, alles Breitere (ganzer Post-Typ, Archiv) deaktiviert. +2. **Wie frei darf der KI-Assistent globale Module ändern?** Jede Änderung wirkt sofort auf allen Seiten, auch veröffentlichten. + - (a) Erlaubt mit Rechten wie im Builder, `confirm: true` und Liste der betroffenen Seiten. **Empfehlung.** + - (b) Nur Administratoren (`unfiltered_html`/`manage_options`). + - (c) Globale Einträge nur lesen, Änderungen nur im Builder. +3. **Was zuerst: Library oder Theme Builder?** + - (a) 3a Library (globale Module, Einfügen, Kopieren), dann 3b Theme Builder. **Empfehlung** (technisch sauberere Grundlage). + - (b) Theme Builder zuerst, weil ein Kunde CPT-Vorlagen braucht (Library-Teil danach). +4. **Pods-Beziehungs- und Dateifelder im Theme Builder:** Divi zeigt davon nur IDs. + - (a) In Etappe 3 nur Text-ähnliche Pods-Felder anbieten, andere als „nicht geeignet“ melden. **Empfehlung.** + - (b) Das Plugin formatiert Pods-Werte beim Rendern (Filter `divi_module_dynamic_content_resolved_custom_meta_value`) – wirkt dann auf der Website, auch ohne MCP. + - (c) Ganz auf später verschieben. +5. **Woher kommen die echten Fixtures?** + - (a) Du baust sie lokal in DDEV nach Checkliste, ein Skript exportiert sie. **Empfehlung.** + - (b) Export von einer Kundenseite (anonymisiert). + - (c) Nur aus Divi-Code abgeleitet (riskant, F13). + +## 4. Risiken + +| Risiko | Gegenmaßnahme | +|---|---| +| Keine echten Library-/TB-Daten lokal (F13); Instanz-Format (`localAttrs`, `innerBlocks`, `globalModule` an der Vorlagenwurzel) nur aus Code/minifiziertem JS abgeleitet | Fixtures zuerst (E16, Frage 5), Builder-Abnahme | +| Späteres Speichern im TB-Editor überschreibt unsere `_et_template`-Zuordnung, Vorlage wird nach 7 Tagen gelöscht (F19) | Ablehnung bei TB-Entwurf (E7), `lit/tb-list` zeigt unzugeordnete Vorlagen | +| Globale Änderung wirkt sofort auf veröffentlichte Seiten | `confirm`, Seitenliste, Revision des Library-Posts (E2, Frage 2) | +| `page-render-summary` sieht globale Module nicht (F8) | Hinweis im Auszug (E13d) | +| `et_theme_builder_*` rufen `wp_die()` statt Fehler zu liefern (F17, F18) | Vorabprüfung der Rechte, Adapter (E3, E15) | +| Divi-Interna ändern sich (Library-Meta, TB-Meta, Instanz-Format); ET kündigt ein offizielles MCP an | Adapter + Kompatibilitätsprüfung (E15), Fixture-Tests je Divi-Update | +| Builder-only-Werte gelangen über Kopie in fremde Posts (Audit M1-Muster) | E6 | +| Pods mit Tabellen-Speicherung: `get_post_meta` findet nichts | `lit/divi-dynamic-sources` meldet solche Felder als ungeeignet (E12) | +| Rollen-Editor: „gefährliche“ Divi-Rechte standardmäßig nur für Administratoren | E3, klare `forbidden`-Meldung mit Hinweis auf Divi → Rollen-Editor | +| Große Websites: find-similar lädt viel `post_content` | Cursor, 200 Posts je Aufruf (E11) | +| Loop Builder / Archive nicht abgedeckt | bewusst später (E12) | + +## 5. Schnittstellen-Einordnung + +Stufen: (a) öffentliche/dokumentierte API (WordPress-Kern, dokumentierte Divi-Hooks), (b) Divis Builder-REST/AJAX (`divi/v1`, admin-ajax, nonce-geschützt), (c) interne PHP-Klassen/Funktionen/Meta-Konventionen, (d) selbst nachgebildete interne Formate. Empfehlung: **B** = behalten, **W** = wechseln auf offizielleren Weg, **A** = in Adapter mit Live-Kompatibilitätsprüfung (E15). + +Zur Offizialität: Elegant Themes dokumentiert Hooks und die Modul-API (dev.elegantthemes.com, u. a. `divi_module_dynamic_content_options`, `divi_module_dynamic_content_resolved_custom_meta_value`, `divi_module_dynamic_content_custom_meta_options`, `divi_module_library_register_module_attrs`), aber keine API für externen oder KI-Zugriff. Der Divi AI Agent läuft im Builder (im Browser, AI-Wallet-Abrechnung); in den Kommentaren zur Ankündigung schreibt ET, ein „official MCP is on the way“. Divi registriert keine WordPress Abilities (F32). + +### 5.1 Bereits genutzt (Etappe 2a/2b) + +| Schnittstelle | Stelle im Plugin | Stufe | Risiko bei Divi-Updates | Empfehlung | +|---|---|---|---|---| +| WordPress-Kern: `wp_update_post`, Revisionen, kses an/aus, Meta-API | `DiviSaver`, `RevisionPoint` | a | gering | B | +| Block-Delimiter-Format (``, WP_Block_Parser-Muster) | `BlockTree/Parser` | a (WP-Format) | gering | B | +| Divi-Blocknamen und Attribut-JSON (`module.decoration…`, `flexType`, `divi/placeholder`) | `Composer`, `AttrWriter`, `ColumnWidths` | d (Schema aus c) | mittel | B – Pfade werden gegen den Live-Katalog validiert; Builder-Fixtures | +| `ModuleRegistration::get_all_core_modules_metadata()`, `get_default_attrs()` | `Schema/DiviModules` | c | mittel | A (Adapter existiert; Prüfung in `lit/site-status` ergänzen) | +| `GlobalData::get_global_colors/-variables`, `GlobalPreset::get_data`, Option `et_divi` | `Design/DesignSystemReader`, `MediaUsageFinder` | c (REST-Pendant `divi/v1/global-data/*` = b) | mittel | A; kein Wechsel auf b (nonce, Builder-Kontext) | +| `DynamicContentOptions::get_options()` | `Pages/DynamicContentMap` | c; Füllweg über dokumentierten Filter `divi_module_dynamic_content_options` (a); REST `divi/v1/dynamic-content/options` (b) | mittel | A (Rückfall auf Standard-Map existiert) | +| `$variable(...)$`-Syntax, gcid/gvid, Dynamic-Content-JSON | `Compose/VariableRef`, `Placeholders` | d | mittel | B + Prüfung gegen Divis Regex `ET_THEME_BUILDER_DYNAMIC_CONTENT_REGEX_D5` (`theme-builder.php:36`) im Kompatibilitätstest | +| Meta `_et_pb_use_divi_5`, `_et_pb_use_builder`, `_et_pb_show_page_creation` | `Pages/DiviFlags`, `PostPresenter` | d (nachgebildet aus `SyncToServerController`) | mittel | B; `_et_pb_use_builder` könnte über `et_builder_enable_for_post()` (c) laufen – kein Gewinn | +| `ET_Core_PageResource::remove_static_resources()` | `DiviSaver` | c (dokumentierter Hook `et_core_page_resource_post_id_before_clear` deutet auf stabile API) | gering | B | +| `et_builder_enabled_for_post_type()` | `ContentSchema` | c | gering | B | +| `ET_BUILDER_PRODUCT_VERSION` | `DiviModules` | c | gering | B | +| Divis `wp_insert_post_data`-Filter (AttributeSecurity, HtmlSecurity) als Verteidigung in der Tiefe | Verhalten, kein Aufruf | c | mittel | B (nie entfernen, S7) | + +### 5.2 Geplant (Etappe 3) + +| Schnittstelle | Zweck | Stufe | Risiko | Empfehlung | +|---|---|---|---|---| +| Post-Typen, Taxonomien, Meta der Library (F1, F2) | Liste, Anlegen (E4) | c/d | mittel | A (`DiviLibraryStore`), Vergleich mit Builder-Fixtures | +| `GlobalLayout::is_global_layout_template()` | „global?“ | c | gering | B (Divis eigene Definition statt Term-Logik) | +| `divi/global-layout`-Platzhalter (`globalModule`, `blockName`, `localAttrs`) | Instanzen (E5, E10) | d | hoch | A + Fixtures; nur minimale Form ohne `localAttrs` erzeugen | +| Entglobalisieren wie `PortabilityPost::maybe_deglobalize_nested_global_modules` | Kopie einfügen (E5) | c | mittel | Eigene Umsetzung (d), per Test gegen Divis Methode geprüft (Aufruf direkt wäre möglich, arbeitet aber auf Strings ohne unsere Byte-Garantie) | +| Builder-Routen `divi/v1/divi-library/*` (F11) | – | b | mittel | nicht nutzen: Nonce, fehlende Pro-Eintrag-Rechte (F9), `create-item` hängt an nicht geladenem Code (F2, F3) | +| `et_pb_is_allowed()` | Divi-Rollen (E3) | c | gering | A (`DiviRoles`) | +| `et_theme_builder_insert_layout`, `et_theme_builder_store_template` | TB schreiben (E7) | c (in Spec genannt) | mittel | B über Adapter `DiviThemeBuilder`, Rechte vorab (wp_die) | +| `et_theme_builder_get_theme_builder_post_id`, `…_get_theme_builder_templates`, `…_get_template` | TB lesen | c | gering | B/A | +| `_et_template`-Zuordnung + Backup-Option (F15, F19) | Vorlage aktivieren | d (admin-ajax-Weg `et_theme_builder_api_save` = b, im REST-Kontext nicht geladen) | mittel | A; bei Divi-Update prüfen, ob `et_theme_builder_api_save` die Logik ändert | +| `et_theme_builder_get_flat_template_settings_options()` + `validate`-Callbacks | Bedingungen (E7) | c | gering | B | +| `et_theme_builder_clear_wp_cache`, `remove_static_resources('all','all',true)` | Cache | c | gering | B | +| `DynamicContentOptions::get_options()` je Post-Typ | `lit/divi-dynamic-sources` | c (Filter a) | mittel | A (vorhandener `DynamicContentMap`) | +| `post_link_url_` + `post_id` | `{{link:ID}}` (E13a) | d (Format), Registry c | gering | B – Name über die Registry prüfen, nicht fest verdrahten | +| `divi_module_dynamic_content_resolved_custom_meta_value` | nur falls Frage 4 = (b) | a | gering | B (dokumentiert) | +| `appearance.themeBuilderLayouts` aus den Modul-Metadaten | StructureRules für TB-Bereiche | c | gering | B (aus Live-Katalog) | +| Loop Builder (`module.advanced.loop`) | – | d | hoch | nicht erzeugen (E12) | +| WordPress Abilities von Divi | – | – | – | gibt es nicht (F32); beobachten, ob ETs angekündigtes MCP eine offizielle Schnittstelle bringt, die c/d-Teile ersetzen kann | + +Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantthemes.com/blog/theme-releases/ai-agent), [Divi Dev Docs – Filter](https://dev.elegantthemes.com/docs/hooks/php/filters/), [Divi Developers](https://www.elegantthemes.com/developers/). + + +## 5. Antworten des Benutzers (2026-09-27) +- F1 Theme-Builder-Vorlagen: standardmäßig deaktiviert angelegt; Aktivieren über ein eigenes Tool (explizit). +- F2 Globale Module bearbeiten: erlaubt mit Trockenlauf (Liste der betroffenen Seiten) und `confirm`, vorher Revision/Sicherung des Bibliothekseintrags. +- F3 Reihenfolge: erst 3a Bibliothek, dann 3b Theme Builder. +- F4 Pods-Beziehungs-/Dateifelder: als dynamische Quelle nur textartige Felder anbieten; andere mit Hinweis auslassen. +- F5 Testdaten: selbst erzeugen („Probier es selbst“) – über Divis eigene Speicherwege (Builder-REST-Controller/Services im Prozess), als „[Fixture] …“ markiert, danach als Fixtures ins Plugin exportiert. Pods ist lokal deaktiviert; Aktivierung für 3b beim Benutzer anfragen. From cfc81ddbb2903c1d749bf91792f4831106e480b1 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:10:27 +0200 Subject: [PATCH 04/47] docs: Etappe 3a implementation plan (Bibliothek) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-27-etappe-3a-bibliothek.md | 7316 +++++++++++++++++ 1 file changed, 7316 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md diff --git a/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md new file mode 100644 index 0000000..63e2b2e --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md @@ -0,0 +1,7316 @@ +# Etappe 3a – Divi-Bibliothek: Auflisten, Speichern, Einfügen, Kopieren, globale Module bearbeiten – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Der KI-Assistent arbeitet mit der Divi-Bibliothek: Einträge auflisten und lesen (`lit/library-list`), Knoten als normalen oder globalen Eintrag speichern (`lit/library-save`), Einträge als Kopie oder als globale Instanz im Format des Visual Builders einfügen (`lit/library-insert`, direkt), Knoten zwischen Seiten kopieren (`lit/divi-node-copy`), Bibliothekseinträge – auch globale, mit Probelauf, `confirm` und Sicherung – mit den vorhandenen Divi-Werkzeugen bearbeiten, verfügbare Dynamic-Content-Quellen abfragen (`lit/divi-dynamic-sources`); dazu die Übernahmen aus Etappe 2 (`{{link:ID}}`, `lit-guide` mit allen Themen, S19) und der Hinweis auf globale Module in `lit/page-render-summary`. + +**Architecture:** Reines PHP in `Divi/BlockTree/` bekommt die Bausteine für globale Module (`Block::copyAs()`, `GlobalInstance` – Instanz im Builder-Format und Entglobalisieren wie Divis `maybe_deglobalize_nested_global_modules` –, `LibraryContent` – Rahmen `divi/placeholder`, Wurzeln, Formregeln). Divi-Interna liegen hinter Adaptern mit Live-Kompatibilitätsprüfung (Entscheidung E15): `DiviRoles` (`et_pb_is_allowed`), `DiviLibraryStore` (Post-Typ `et_pb_layout`, Terme, Meta, `GlobalLayout::is_global_layout_template`), `DiviCompatibility` (Befund in `lit/site-status`). `LibraryAccess` bündelt die Rechte je Objekt (E3) und wird in `DiviPageLoader` eingehängt, damit alle Divi-Werkzeuge Bibliotheks-IDs annehmen (E1); `GlobalEdits` ergänzt `DiviWriter::commit()` um Formregeln, Probelauf mit betroffenen Seiten, `confirm` und Sicherung (E2, Antwort F2). Jeder neue Weg nach `post_content` läuft weiter über `Composer`/`TreeEditor`/`DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard` gegen die S7/S26-Sperren (E6). Die neuen Tools bilden die Gruppe „Divi-Bibliothek“ (`LibraryIntegration`), `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, kses, Taxonomien), Divi 5.2.1 (`et_pb_layout`, `et_pb_is_allowed`, `GlobalLayout`, `DynamicContentOptions`, `ET_Core_PageResource`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §5.1–5.7, §8.1–8.2, §9, §10, §12 Etappe 3) und die Entscheidungen `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F1–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5; **die Antworten des Benutzers in „5. Antworten des Benutzers“ sind verbindlich**), dazu die Rulings `2026-09-26-etappe-{1a,1b,2a,2b}-rulings.md`, `2026-09-26-etappe-2-entscheidungen.md` (D1–D12, R2), Security-Audit und Praxistest 2b sowie die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Commit e7cc4ef). + +**Voraussetzung:** Branch `etappe-3` mit Stand 30788f2 (Etappe 2b vollständig, 1597 Tests grün). Pods ist lokal deaktiviert; `lit/divi-dynamic-sources` wird für Pods über Unit-Tests abgesichert. + +## Global Constraints + +- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber. +- `src/Integrations/Divi/BlockTree/*` und `src/Integrations/Divi/Compose/*` bleiben reines PHP ohne WordPress-Aufrufe (Spec §4.4). WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/Design/`, `Divi/Schema/` und in `DiviCompatibility`. +- **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder neue Schreibweg in `post_content` – Seiten wie Bibliothekseinträge – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durch `StoredNodeGuard` (S7/S26-Pfade, Code-Modul, `$variable`-Einstellungen) – für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (`divi/v1/…`), kein `et_pb_submit_layout`/`et_pb_create_layout`. +- **Rechte je Objekt (Spec §4.5, E3 – strenger als Divi, nie lockerer):** Bibliothek lesen/auflisten = `edit_posts` + Divi-Rolle `divi_library`; einen Eintrag lesen = zusätzlich `edit_post` auf den Eintrag; speichern/anlegen = + `publish_posts` des Typs `et_pb_layout` + `save_library`; bearbeiten = `edit_post` + `save_library`, bei globalen Einträgen + `edit_global_library`; einfügen = + `add_library`; Dynamic-Content-Feldliste nur mit `read_dynamic_content_custom_fields`. Divi-Rollen nur über `DiviRoles`. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeuge `not_found` wie bisher (`ContentSchema::EXCLUDED` bleibt unverändert). +- **Sperren und Alles-oder-nichts:** Jede Divi-Schreibung verlangt `tree_rev` (Ruling P16); Werkzeuge mit Quellknoten (`lit/library-save`, `lit/divi-node-copy`) verlangen zusätzlich die `tree_rev` der Quelle. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann einmal speichern; beim Anlegen eines Bibliothekseintrags wird der Eintrag bei jedem Fehler endgültig gelöscht (wie `lit/content-create`). Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7). +- **Globale Einträge (Antwort F2, verbindlich):** Jede Änderung an einem globalen Bibliothekseintrag ist zuerst ein Probelauf (`confirm_required` mit `affected`/`affected_count`, nichts gespeichert); mit `confirm: true` wird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (`PostCloner::backup`, `backup_id`) –, danach seitenweit `remove_static_resources('all', 'all', true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5). +- **Exposition:** Direkt ist nur `lit/library-insert` (22 direkte Tools, Spec §4.3). Indirekt: `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Gruppe „Divi-Bibliothek“ (`divi-library`) für die drei `lit/library-*`-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (`ExposurePolicy::groupWritable('divi-library')`). `lit/library-insert` folgt zusätzlich `lit/divi-insert` (Audit M2). Jeder Task mit neuem direktem Tool aktualisiert `docs/guides/overview.md` „Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Tools `lit-`, indirekte `lit/` (ToolNamingTest). In PHP-Texten nie `topic ""` für ein Thema ohne `docs/guides/.md` schreiben (ToolNamingTest). +- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme und Abnahme auf Deutsch mit echten Umlauten (ä, ö, ü, ß). Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“ – keine Chatbot-Marken in UI, Guides, Readme oder Tool-Texten. +- Tests: Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- `composer test` braucht mehr als 128 MB (Task 1 behebt das); danach laufen `composer test` und `php vendor/bin/phpunit` mit jeder `memory_limit`-Vorgabe. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden kurz „Commit wie in den Global Constraints“). + +## Review Focus + +1. **Redakteur ohne `unfiltered_html` überträgt gespeicherten Admin-Inhalt in einen anderen Post** (Abschnitt mit eigenem CSS, HTML-Attributen, Code-Modul oder Dynamic-Content-`before`/`after` mit HTML) über `lit/divi-node-copy`, `lit/library-insert` oder `lit/library-save`: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → Tests `StoredNodeGuardTest` (Task 11), `DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage` (Task 14), `LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss` (Task 13), `LibrarySaveAbilityTest::testEditorCannotSaveACodeModule` (Task 12). +2. **Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per `lit-divi-delete`, das ein eigenes `confirm` hat:** Ohne `confirm` nichts gespeichert, Antwort nennt die betroffenen Seiten; mit `confirm` Revision bzw. Sicherung und seitenweite Cache-Leerung → Tests `GlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing`, `::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm`, `::testWithoutRevisionsABackupIsMadeFirst` und `DiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage` (Task 9). +3. **Bibliothekseintrag verliert seine Form** (Wurzel gelöscht, zweite Wurzel, anderer Wurzeltyp, globales Modul in einem globalen Eintrag): Instanzen auf allen Seiten würden brechen → abgelehnt → Tests `LibraryContentTest::testShapeViolations` (Task 7) und `GlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot` (Task 9). +4. **Rechte:** Autor ohne Divi-Rolle `divi_library`/`save_library`/`add_library`/`edit_global_library`, fremder Eintrag ohne `edit_post`, Mitarbeiter ohne `publish_posts`, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhalten `forbidden`, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDs `not_found` → Tests `LibraryAccessTest` (Task 8), `DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess` (Task 8), `LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole` (Task 12). +5. **Parallele Änderung:** Quelle oder Ziel wurde seit dem Lesen geändert (`source_tree_rev`/`tree_rev` veraltet), oder der globale Eintrag änderte sich zwischen Probelauf und `confirm` → `rev_mismatch`, nichts geschrieben → Tests `DiviNodeCopyAbilityTest::testStaleSourceOrTargetWritesNothing` (Task 14), `LibrarySaveAbilityTest::testStaleSourceTreeRevCreatesNothing` (Task 12), `GlobalEditsTest::testConfirmWithAStaleTreeRevWritesNothing` (Task 9). + +--- + +## File Structure + +``` +phpunit.xml.dist (geändert) memory_limit 512M +composer.json (geändert) test-Skript mit memory_limit +wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.4.0; Verdrahtung Bibliothek + Gates +readme.txt (geändert) Bibliothek, globale Module, Changelog 0.4.0 +docs/guides/ + divi-library.md (neu) Thema „divi-library“ + overview.md, divi-placeholders.md, divi-structure.md, workflows.md, divi-design-system.md (geändert) +docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §5.2, §5.4, §5.7, §9.2 +docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 6 „Umsetzung 3a“ +docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md (neu) manuelle Abnahme +src/ + Abilities/ExposurePolicy.php (geändert) groupWritable() + Guides/InstructionsBuilder.php (geändert) Bibliotheks-Regel + Integrations/Core/EnvironmentProbe.php (geändert) Divi-Kompatibilitätsbefund + Integrations/Core/GuideAbility.php (geändert) topic = all + Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) Hinweis globale Module + Integrations/Divi/ + DiviIntegration.php (geändert) Bibliothekszugang, library(), neue Divi-Tools + DiviCompatibility.php (neu) Live-Prüfung der genutzten Divi-Interna + BlockTree/ + Block.php (geändert) copyAs() + Container.php (geändert) replaceChild(), unifyLineBreaks() + NodeSummary.php (geändert) globalRef() + Outline.php (geändert) global_id + GlobalInstance.php (neu) Instanz im Builder-Format, entglobalisieren, effectiveName() + LibraryContent.php (neu) Rahmen, Wurzeln, Formregeln + Compose/ + LinkTargets.php (neu) Schnittstelle für {{link:ID}} + Placeholders.php (geändert) {{link:ID}} + VariableRef.php (geändert) post_link_url_* nur in URL-Feldern, settingsOf() + StructureRules.php, TreeEditor.php (geändert) Wurzeltyp globaler Instanzen + StoredNodeGuard.php (neu) S7/S26 für übertragene gespeicherte Knoten + Design/DiviDesignSystemAbility.php (geändert) only/module-Filter (S19) + Pages/ + WpLinkTargets.php (neu) + SaveScope.php (neu) Seite / Bibliothekseintrag / globaler Eintrag + DiviSaver.php (geändert) SaveScope: Flags, seitenweiter Cache + DiviPage.php, DiviPageLoader.php (geändert) Bibliothekseinträge + DiviWriter.php, DiviWriteAbility.php (geändert) commit(), notice(), precheck() + DiviSchema.php (geändert) confirm + DiviComposition.php (geändert) LinkTargets, storedGuard() + DiviNodeGetAbility.php, DiviOutlineAbility.php (geändert) global + DynamicContentMap.php (geändert) options() + DiviNodeCopyAbility.php (neu) lit/divi-node-copy + DiviDynamicSourcesAbility.php (neu) lit/divi-dynamic-sources + Library/ + DiviRoles.php (neu) Adapter et_pb_is_allowed + DiviLibraryStore.php (neu) Adapter et_pb_layout, Terme, Meta + LibraryAccess.php (neu) Rechte je Objekt, Gruppen-Gate + LibraryItemSpec.php (neu) Terme/Meta eines neuen Eintrags + GlobalUsages.php (neu) Seiten mit Instanzen + GlobalEdits.php (neu) Formregeln, Probelauf, confirm, Sicherung + LibraryIntegration.php (neu) Gruppe „Divi-Bibliothek“ + LibraryListAbility.php (neu) lit/library-list + LibrarySaveAbility.php (neu) lit/library-save + LibraryInsertAbility.php (neu) lit/library-insert +tests/ + Stubs/EtCorePageResource.php (neu) zeichnet remove_static_resources auf + bootstrap.php (geändert) + Support/ComposeFixture.php, DiviWriterFixture.php (geändert) links(), Bibliothek + Support/LibraryFixtures.php (neu) Posts nach ID, Store/Rollen/Zugang + Fixtures/divi/stage3/README.md (geändert) Befund 9: CRLF in Bibliothekseinträgen + Unit/… (neu/geändert je Task) + Integration/divi-library-smoke.sh (neu) Ende-zu-Ende 3a +``` + +--- + +### Task 1: Testlauf ohne Speicherfehler + +`composer test` bricht mit dem PHP-Standard `memory_limit=128M` ab („Allowed memory size of 134217728 bytes exhausted“); der volle Lauf braucht ≈ 177 MB (gemessen: `php -d memory_limit=1G vendor/bin/phpunit` → „Memory: 177.00 MB“). Das ist kein Leck einzelner Tests, sondern die Summe aus 1597 Tests mit Mockery/Brain Monkey und dem 237-KB-Katalog, der je Test dekodiert wird. PHPUnit setzt Werte aus `` zur Laufzeit; das deckt `composer test` und den direkten Aufruf ab. + +**Files:** +- Modify: `phpunit.xml.dist` +- Modify: `composer.json` (`scripts.test`) + +**Interfaces:** +- Consumes: nichts. +- Produces: `composer test` und `php vendor/bin/phpunit` laufen unabhängig von der `memory_limit` der CLI. + +- [ ] **Step 1: Fehler nachweisen** + +Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | grep -m1 -o 'Allowed memory size of [0-9]* bytes exhausted'` +Expected: `Allowed memory size of 134217728 bytes exhausted` + +- [ ] **Step 2: Grenze in der PHPUnit-Konfiguration setzen** + +`phpunit.xml.dist` vollständig: + +```xml + + + + + + + + + tests/Unit + + + +``` + +- [ ] **Step 3: Composer-Skript absichern (greift schon vor dem Laden der Konfiguration)** + +In `composer.json` den Block `scripts` ersetzen durch: + +```json + "scripts": { + "test": "@php -d memory_limit=512M vendor/bin/phpunit", + "lint": "@php vendor/bin/phpcs" + }, +``` + +- [ ] **Step 4: Prüfen** + +Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | tail -1` +Expected: `OK (1597 tests, 7869 assertions)` + +Run: `composer test 2>&1 | tail -1` +Expected: `OK (1597 tests, 7869 assertions)` + +- [ ] **Step 5: Commit** + +```bash +git add phpunit.xml.dist composer.json +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: raise memory_limit for the unit suite (512M)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: Interner Link `{{link:ID}}` (E13a) + +Divis natives Dynamic Content `post_link_url_` mit `settings.post_id` rendert zur Laufzeit `esc_url(get_permalink($id))` (F27, `DynamicContentOptionCustomPostLinkUrl.php:186-217`) – entwurfssicher und nach dem Veröffentlichen automatisch die schöne URL. Der Name wird nicht fest verdrahtet: `VariableRef` prüft ihn gegen Divis Registry (nur öffentliche Post-Typen, `et_builder_get_public_post_types`), `select_post` prüft `read_post` (S37). Neu: `post_link_url_*` nur in URL-Feldern. + +**Files:** +- Create: `src/Integrations/Divi/Compose/LinkTargets.php` +- Create: `src/Integrations/Divi/Pages/WpLinkTargets.php` +- Modify: `src/Integrations/Divi/Compose/Placeholders.php` +- Modify: `src/Integrations/Divi/Compose/VariableRef.php` (`contentSettings()`) +- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` +- Modify: `tests/Support/ComposeFixture.php` (`links()`, `placeholders()`) +- Test: `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`, `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`, `tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php` + +**Interfaces:** +- Consumes: `VariableRef::serialize(string $type, string $name, array $settings): string`, `DynamicContentMap::fromOptions(array $options): array`, `DiviFixtures::dynamicContentRegistry()` (enthält `post_link_url_page` mit `post_id` = `select_post`). +- Produces: `interface LinkTargets { public function postTypeOf(int $id): ?string; }`; `new Placeholders(DesignSystem $design, ?LinkTargets $links = null)`; `WpLinkTargets implements LinkTargets`; `DiviComposition::__construct(…, LinkTargets $links = new WpLinkTargets())` (6. Parameter); `ComposeFixture::links(): LinkTargets` (839 → page, 840 → post, sonst null). + +- [ ] **Step 1: Failing tests schreiben** + +In `tests/Support/ComposeFixture.php` ergänzen (Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`) und `placeholders()` ersetzen: + +```php + /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */ + public static function links(): LinkTargets + { + return new class implements LinkTargets { + public function postTypeOf(int $id): ?string + { + return match ($id) { + 839 => 'page', + 840 => 'post', + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DiviFixtures::designSystem(), self::links()); + } +``` + +In `PlaceholdersTest::placeholders()` einen Eintrag ergänzen: + +```php + 'internal link' => [ + '{{link:839}}', + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + ], +``` + +Da `testResolvesToDiviVariables` das Ergebnis mit `ComposeFixture::references()` (Standard-Map ohne `post_link_url_page`) prüft, dort die Prüfung auf den Registry-Checker umstellen: + +```php + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $placeholders = ComposeFixture::placeholders(); + $resolved = $placeholders->resolve($input); + + self::assertSame($expected, $resolved); + if ($expected !== $input) { + // What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live + // registry map knows post_link_url_page, the URL mode allows it. + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), true)); + } + } +``` + +(Imports: `use LIT\MCP\Integrations\Divi\Compose\VariableRef;`, `use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;`, `use LIT\MCP\Tests\Support\DiviFixtures;`.) Hinweis: Im URL-Modus sind `before`/`after` nur leer erlaubt – alle bisherigen Zeilen liefern leere `before`/`after`, sie bleiben gültig. + +In `PlaceholdersTest::invalid()` ergänzen: + +```php + 'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'], + 'link to a missing post' => ['{{link:999}}', 'Eintrag 999'], + 'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'], +``` + +und am Ende von `PlaceholdersTest` einen Test ohne `LinkTargets`: + +```php + public function testLinksNeedLinkTargets(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Eintrag 839'); + + (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}'); + } +``` + +In `ShortformWriterTest` (Imports `ShortformWriter`, `Placeholders`, `ValueCleaner`, `VariableRef`, `DynamicContentMap`, `DiviFixtures`): + +```php + /** E13a: {{link:ID}} becomes Divi's post_link_url_ in URL fields – and only there. */ + public function testInternalLinkOnlyInUrlFields(): void + { + $design = DiviFixtures::designSystem(); + $cleaner = new ValueCleaner( + DiviFixtures::catalog(), + new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())), + ComposeFixture::sanitizer() + ); + $writer = new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); + + $button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n'); + self::assertSame([], $button['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + $button['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + + $text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']); + + // A post type Divi offers no link option for (not public): unknown name. + $post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n'); + self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']); + } +``` + +`tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php`: + +```php + new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']), + 841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']), + 842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']), + 843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']), + ]; + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842 + ); + + $links = new WpLinkTargets(); + + self::assertSame('page', $links->postTypeOf(839)); + self::assertNull($links->postTypeOf(841), 'trashed'); + self::assertNull($links->postTypeOf(842), 'not readable'); + self::assertNull($links->postTypeOf(843), 'revision'); + self::assertNull($links->postTypeOf(900), 'missing'); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – sie schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest'` +Expected: FAIL (`Class "LIT\MCP\Integrations\Divi\Compose\LinkTargets" not found`) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Compose/LinkTargets.php`: + +```php +}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress. + */ +interface LinkTargets +{ + /** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */ + public function postTypeOf(int $id): ?string; +} +``` + +`src/Integrations/Divi/Pages/WpLinkTargets.php`: + +```php +}} targets from WordPress: the entry exists, lives (no trash, auto-draft or + * revision) and the current user may read it (read_post, like select_post settings, S37). + */ +final class WpLinkTargets implements LinkTargets +{ + private const DEAD = ['trash', 'auto-draft', 'inherit']; + + public function postTypeOf(int $id): ?string + { + $post = get_post($id); + if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) { + return null; + } + + return current_user_can('read_post', $id) ? (string) $post->post_type : null; + } +} +``` + +`Placeholders.php`: Konstruktor, Muster, Fehlermeldung und neue Methode. + +```php + private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/'; + + public function __construct(private DesignSystem $design, private ?LinkTargets $links = null) + { + } +``` + +In `resolve()` die Meldung zu „(bekannt: color, var, font, post, field, link)“ ändern und das `match` erweitern: + +```php + return match ($kind) { + 'color' => $this->color($name), + 'var' => $this->variable($name), + 'font' => $this->font($name), + 'post' => $this->post($name), + 'link' => $this->link($name), + default => $this->field($name), + }; +``` + +Neue Methode (Klassen-Docblock um `{{link:}}` ergänzen): + +```php + /** + * {{link:}} → Divi's own internal link post_link_url_ with settings.post_id (F27): + * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published. + * Whether Divi offers that option (public post types only) VariableRef checks against the registry. + */ + private function link(string $name): string + { + if (preg_match('/^[1-9][0-9]{0,19}$/D', $name) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).', + $name + )); + } + + $type = $this->links?->postTypeOf((int) $name); + if ($type === null) { + throw new InvalidArgumentException(sprintf( + 'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.', + $name + )); + } + + return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]); + } +``` + +`VariableRef::contentSettings()` – vor der Schleife einfügen: + +```php + if (! $url && str_starts_with($name, 'post_link_url_')) { + throw new InvalidArgumentException(sprintf( + '"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.', + $name + )); + } +``` + +`DiviComposition`: Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`, Konstruktor um `private LinkTargets $links = new WpLinkTargets()` als letzten Parameter erweitern und `placeholders()` ändern: + +```php + return $this->placeholders ??= new Placeholders($this->design->read(), $this->links); +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest|VariableRefTest|DiviCompositionTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK / keine Fehler + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Compose/LinkTargets.php src/Integrations/Divi/Pages/WpLinkTargets.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content`. + +--- + +### Task 3: `lit-guide` mit allen Themen (E13b) + +Praxistest-Wunsch 7: alle Themen in einem Aufruf. `topic` = `all` liefert zuerst `overview`, dann die übrigen Themen alphabetisch; Obergrenze 100 000 Bytes, nicht aufgenommene Themen werden am Ende genannt. + +**Files:** +- Modify: `src/Integrations/Core/GuideAbility.php` +- Test: `tests/Unit/Integrations/Core/GuideAbilityTest.php` + +**Interfaces:** +- Consumes: `GuideRepository::topics(): list`, `GuideRepository::get(string $topic): ?string`. +- Produces: `lit/guide` mit `topic` = `all` → `{topic: "all", topics: list, content: string}`; Konstante `GuideAbility::ALL_LIMIT = 100000`. + +- [ ] **Step 1: Failing test schreiben** + +In `GuideAbilityTest` ergänzen (`$this->ability` liest `tests/Fixtures/guides` mit `overview.md` und `divi-shortform.md`): + +```php + public function testAllTopicsInOneCallOverviewFirst(): void + { + $result = $this->ability->execute(['topic' => 'all']); + + self::assertSame('all', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + $overview = strpos($result['content'], 'Thema `overview`'); + $shortform = strpos($result['content'], 'Thema `divi-shortform`'); + self::assertIsInt($overview); + self::assertIsInt($shortform); + self::assertLessThan($shortform, $overview); + self::assertStringNotContainsString('Nicht enthalten', $result['content']); + } + + public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void + { + $big = str_repeat('x', GuideAbility::ALL_LIMIT); + // Brain Monkey owns apply_filters(): answer the guide filter through its hooks API. + Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]); + $ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + + $content = $ability->execute(['topic' => 'all'])['content']; + + self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content)); + self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content); + } +``` + +(Import `use Brain\Monkey\Filters;`.) + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter GuideAbilityTest` +Expected: FAIL (`Undefined constant … ALL_LIMIT` bzw. `Unbekanntes Thema`) + +- [ ] **Step 3: Implementieren** + +In `GuideAbility`: + +```php + private const DEFAULT_TOPIC = 'overview'; + + /** topic = all: every topic in one call (overview first). */ + public const ALL = 'all'; + + /** Upper bound of the combined text of topic = all (bytes). */ + public const ALL_LIMIT = 100000; +``` + +`description()`: + +```php + return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; ' + . 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).'; +``` + +Beschreibung des Schema-Felds `topic`: `'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.'` + +In `execute()` direkt nach `$topics = $this->guides->topics();`: + +```php + if ($topic === self::ALL) { + return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)]; + } +``` + +Neue Methode: + +```php + /** + * Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names + * the topics left out. + * + * @param list $topics + */ + private function all(array $topics): string + { + $ordered = in_array(self::DEFAULT_TOPIC, $topics, true) + ? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC]))) + : $topics; + $parts = []; + $size = 0; + $left = []; + foreach ($ordered as $topic) { + $content = $this->guides->get($topic); + if ($content === null) { + continue; + } + + $part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content)); + if ($size + strlen($part) > self::ALL_LIMIT) { + $left[] = $topic; + continue; + } + + $parts[] = $part; + $size += strlen($part) + 2; + } + + if ($left !== []) { + $parts[] = sprintf( + 'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.', + implode(', ', $left) + ); + } + + return implode("\n\n", $parts); + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'GuideAbilityTest|ToolNamingTest'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Core/GuideAbility.php tests/Unit/Integrations/Core/GuideAbilityTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit-guide topic all returns every guide in one call`. + +--- + +### Task 4: S19 – Designsystem filtern, globale Module in `node-get`/`outline` + +Ruling S19 (aus 2a F2): `lit-divi-design-system` wird auf großen Websites mit hunderten Presets unhandlich → Filter `only` (colors/variables/presets) und `module` (nur dessen Presets), höchstens 200 Presets je Antwort. `lit-divi-node-get` und `lit-divi-outline` nennen bei globalen Modulen die Bibliotheks-ID und den Wurzeltyp (E2). + +**Files:** +- Modify: `src/Integrations/Divi/Design/DiviDesignSystemAbility.php` +- Modify: `src/Integrations/Divi/BlockTree/NodeSummary.php` (`globalRef()`) +- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (`global_id`) +- Modify: `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` (`global`) +- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php` (Schema, Hinweis) +- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `DesignSystem::presets(): list`, Fixture `tests/Fixtures/divi/stage3/page--globale-instanzen.html` (g0 = Instanz 1058 `divi/section`, s0.r0.c0.g0 = Instanz 1059 `divi/text`). +- Produces: `NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}`; Outline-Zeilen mit `global_id: ?string`; `lit/divi-node-get` mit `global: null|{id, block_name, hint}`; `lit/divi-design-system` mit `only`, `module`. + +- [ ] **Step 1: Failing tests schreiben** + +`OutlineTest::testRowsAndTextOfAPage`: im erwarteten Array `'truncated' => false]` durch `'truncated' => false, 'global_id' => null]` ersetzen. Neuer Test in `OutlineTest`: + +```php + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline(); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertSame('1058', $byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertFalse($byId['g0']['editable']); + self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']); + self::assertSame('text', $byId['s0.r0.c0.g0']['type']); + self::assertNull($byId['s0']['global_id']); + self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows)); + } +``` + +(Import `use LIT\MCP\Tests\Support\DiviFixtures;` – `DiviFixtures::markup()` ist reines PHP und liest `tests/Fixtures/divi/`.) + +In `DiviReadAbilitiesTest`: + +```php + public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + + $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); + + self::assertSame('1058', $global['global']['id']); + self::assertSame('divi/section', $global['global']['block_name']); + self::assertStringContainsString('post_id 1058', $global['global']['hint']); + self::assertFalse($global['editable']); + self::assertNull($plain['global']); + } +``` + +In `DiviDesignSystemAbilityTest` (Muster der vorhandenen Tests: Reader-Mock mit `DiviFixtures::designSystem()`): + +```php + public function testFiltersPartsAndPresetsOfOneModule(): void + { + Functions\when('current_user_can')->justReturn(true); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); + $ability = new DiviDesignSystemAbility($reader); + + $result = $ability->execute(['only' => ['presets'], 'module' => 'button']); + + self::assertSame([], $result['colors']); + self::assertSame([], $result['variables']); + self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id')); + self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module')))); + self::assertNotEmpty($result['fonts']['heading']); + self::assertContains('Nicht angefordert: colors, variables.', $result['notes']); + } + + public function testCapsThePresetList(): void + { + Functions\when('current_user_can')->justReturn(true); + $items = []; + for ($i = 0; $i < 250; ++$i) { + $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text']; + } + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn($design); + + $result = (new DiviDesignSystemAbility($reader))->execute([]); + + self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); + self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + } +``` + +(Imports: `DesignSystem`, `DesignSystemReader`, `DiviFixtures`, `Mockery`, `Functions`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest'` +Expected: FAIL (`global_id`, `global`, `PRESET_LIMIT` fehlen) + +- [ ] **Step 3: Implementieren** + +`NodeSummary`: + +```php + /** + * Library entry of a global module: a divi/global-layout instance (its root type in blockName) + * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block. + * + * @return array{id: string, block_name: string}|null + */ + public static function globalRef(Block $block): ?array + { + if (! $block->isGlobal()) { + return null; + } + + $attrs = $block->attrs(); + $id = $attrs['globalModule'] ?? null; + if (! is_string($id) && ! is_int($id)) { + return null; + } + + $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name(); + + return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : '']; + } +``` + +`Outline::row()` – Typ und `global_id` (Docblock-Rückgabetyp um `global_id: ?string` ergänzen): + +```php + private function row(Block $block, string $id, int $depth): array + { + $editable = $block->isDivi() && ! $block->isOpaque(); + $global = NodeSummary::globalRef($block); + $type = $block->isDivi() ? $block->shortName() : $block->name(); + if ($global !== null && $global['block_name'] !== '') { + $type = str_starts_with($global['block_name'], 'divi/') ? substr($global['block_name'], 5) : $global['block_name']; + } + + return [ + 'id' => $id, + 'type' => $type, + 'label' => NodeSummary::label($block), + 'excerpt' => NodeSummary::excerpt($block), + 'info' => $editable ? NodeSummary::info($block) : '', + 'presets' => array_map( + fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, + NodeSummary::presetIds($block) + ), + 'children' => $editable ? count($block->children()) : 0, + 'editable' => $editable, + 'depth' => $depth, + 'truncated' => false, + 'global_id' => $global['id'] ?? null, + ]; + } +``` + +`NodeSummary::label()` liest `module.meta.adminLabel` aus den Attributen; bei `divi/global-layout` steht das Label in `localAttrs`. In `NodeSummary::label()` die erste Zeile ersetzen durch: + +```php + $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) + ? $block->attrs()['localAttrs'] + : $block->attrs(); + $label = AttrMap::desktop($attrs, 'module.meta.adminLabel'); +``` + +`Outline::text()` – nach der Label-Zeile: + +```php + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; +``` + +`DiviOutlineAbility::outputSchema()` – in den Zeilen-`properties` `'global_id' => ['type' => ['string', 'null'], 'description' => 'Bibliotheks-ID eines globalen Moduls.']` ergänzen und `'global_id'` in `required` aufnehmen. In `hints()` den Text für nur lesbare Knoten ersetzen: + +```php + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' + . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; +``` + +`DiviNodeGetAbility`: im `outputSchema` `'global' => ['type' => ['object', 'null']]` ergänzen (nicht in `required`); in `execute()` vor dem `return`: + +```php + $global = NodeSummary::globalRef($block); + if ($global !== null) { + $global['hint'] = sprintf( + 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' + . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', + $global['id'], + $global['id'] + ); + } +``` + +und `'global' => $global,` ins Ergebnis-Array. + +`DiviDesignSystemAbility`: + +```php + /** Presets per answer (ruling S19); narrow with "module". */ + public const PRESET_LIMIT = 200; + + private const PARTS = ['colors', 'variables', 'presets']; +``` + +`description()` um „Filter: only (colors/variables/presets) und module (Presets eines Moduls).“ ergänzen. `inputSchema()`: + +```php + return [ + 'type' => 'object', + 'properties' => [ + 'only' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::PARTS], + 'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer dabei.', + ], + 'module' => [ + 'type' => 'string', + 'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".', + ], + ], + 'additionalProperties' => false, + ]; +``` + +`execute()` vollständig: + +```php + public function execute(array $input): mixed + { + $design = $this->reader->read(); + $only = is_array($input['only'] ?? null) && $input['only'] !== [] + ? array_values(array_intersect(self::PARTS, $input['only'])) + : self::PARTS; + $notes = [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ]; + $skipped = array_values(array_diff(self::PARTS, $only)); + if ($skipped !== []) { + $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.'; + } + + $presets = $design->presets(); + $module = is_string($input['module'] ?? null) ? trim($input['module']) : ''; + if ($module !== '') { + $name = str_contains($module, '/') ? $module : 'divi/' . $module; + $presets = array_values(array_filter( + $presets, + static fn (array $preset): bool => $preset['module'] === $name + )); + } + if (count($presets) > self::PRESET_LIMIT) { + $notes[] = sprintf( + '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', + self::PRESET_LIMIT, + count($presets) + ); + $presets = array_slice($presets, 0, self::PRESET_LIMIT); + } + + return [ + 'colors' => in_array('colors', $only, true) ? array_map( + static fn (array $color): array => $color + [ + 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), + ], + $design->colors() + ) : [], + 'variables' => in_array('variables', $only, true) ? array_map( + static fn (array $variable): array => $variable + + ['placeholder' => '{{var:' . $variable['label'] . '}}'], + $design->variables() + ) : [], + 'fonts' => $design->fonts(), + 'sizes' => $design->sizes(), + 'presets' => in_array('presets', $only, true) ? $presets : [], + 'notes' => $notes, + ]; + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest|DiviWriterTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Design/DiviDesignSystemAbility.php src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php src/Integrations/Divi/Pages/DiviOutlineAbility.php tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: design-system filters and global module ids in node-get/outline (S19)`. + +--- + +### Task 5: `lit-page-render-summary` meldet globale Module (E13d, F8) + +Divis `BlockParser` expandiert `divi/global-layout` nicht bei REST-POST-Anfragen (F8, `BlockParser.php:827-831`) – unsere MCP-Aufrufe sind REST-POST, der Auszug enthält globale Module also nicht. Statt einen Divi-internen Schalter zu umgehen, meldet der Auszug jede Instanz (Divis eigene Erkennung per Zeichenkettensuche, F10). Bibliotheks-IDs bleiben für das Tool `not_found` (`ContentSchema::EXCLUDED`). + +**Files:** +- Modify: `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php` +- Test: `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php` + +**Interfaces:** +- Consumes: `PageRenderer::render(WP_Post): string`, `RenderSummary::fromHtml()`. +- Produces: `notes` enthält je globaler Instanz `Globales Modul ist im Auszug nicht enthalten …` (höchstens 20). + +- [ ] **Step 1: Failing test schreiben** + +Im vorhandenen Test das Muster zum Stubben von `get_post`, `current_user_can`, `get_permalink` und des `PageRenderer`-Mocks übernehmen (siehe bestehende Tests der Datei): + +```php + public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer); + + $notes = $ability->execute(['post_id' => 7])['notes']; + + $globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul'))); + self::assertCount(2, $globals); + self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]); + self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]); + } + + public function testLibraryEntriesAreNoManagedContent(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout'])); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + $ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class)); + + self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found'); + } +``` + +(Imports: `ContentSchema`, `SchemaFixture`, `DiviFixtures`, `PageRenderer`, `Mockery`, `WP_Post`, `Functions`.) + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter PageRenderSummaryAbilityTest` +Expected: FAIL (`assertCount(2, …)`: 0) + +- [ ] **Step 3: Implementieren** + +In `PageRenderSummaryAbility`: + +```php + /** At most this many global-module notes per summary. */ + private const GLOBAL_NOTES = 20; +``` + +In `execute()` nach dem Passwort-Hinweis: + +```php + foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) { + $notes[] = sprintf( + 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' + . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', + $id, + $id + ); + } +``` + +Neue Methode: + +```php + /** + * IDs of the global modules in $content, found the way Divi finds them (a string scan for + * "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there). + * + * @return list + */ + private static function globalModuleIds(string $content): array + { + preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches); + + return array_values(array_unique($matches[1])); + } +``` + +`description()` um den Satz „Globale Module (Divi-Bibliothek) fehlen im Auszug und werden unter notes genannt.“ ergänzen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|ToolNamingTest'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress/Render/PageRenderSummaryAbility.php tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: render summary notes global modules it cannot show (F8)`. + +--- + +### Task 6: Divi-Adapter mit Kompatibilitätsprüfung – `DiviRoles`, `DiviLibraryStore` (lesen), `DiviCompatibility` + +Schnittstellen-Einordnung (Entscheidungen §5, E15): Divis Rollen-Editor (`et_pb_is_allowed`, Stufe c), der Bibliotheks-Post-Typ mit Termen/Meta (c/d) und `GlobalLayout::is_global_layout_template` (c) werden nur über Adapter angesprochen. Fehlt eine Funktion/Klasse nach einem Divi-Update, liefern die Adapter „nicht erlaubt“ bzw. den Rückfall, nie einen Fatal; `lit-site-status` zeigt den Befund. Die Builder-Routen `divi/v1/divi-library/*` werden nicht benutzt (Nonce, keine Rechte je Eintrag, F9/F11). + +**Files:** +- Create: `src/Integrations/Divi/Library/DiviRoles.php` +- Create: `src/Integrations/Divi/Library/DiviLibraryStore.php` +- Create: `src/Integrations/Divi/DiviCompatibility.php` +- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`REGISTRY` öffentlich) +- Modify: `src/Integrations/Core/EnvironmentProbe.php` +- Test: `tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`, `tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`, `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`, `tests/Unit/Integrations/Core/EnvironmentProbeTest.php` + +**Interfaces:** +- Consumes: `Errors::forbidden()`, `DiviModules::isAvailable()`, `WP_Term`-Stub. +- Produces: + - `class DiviRoles { __construct(?Closure $check = null); allows(string $capability): bool; refusal(string $capability): ?WP_Error }` + - `class DiviLibraryStore { const POST_TYPE = 'et_pb_layout'; const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout'; const LAYOUT_TYPES = ['layout','section','row','module']; const TAXONOMIES = ['scope','layout_type','module_width']; isGlobal(int $id): bool; layoutType(int $id): ?string; termSlugs(int $id, string $taxonomy): list; termNames(int $id, string $taxonomy): list; find(array $filters, int $limit, int $offset): list; describe(WP_Post $post): array; missingTaxonomies(): list }` (nicht final, mockbar; `create()`/`delete()` folgen in Task 12) + - `final class DiviCompatibility { __construct(?array $checks = null); problems(): list; static defaults(): array }` + - `EnvironmentProbe::__construct(?DiviCompatibility $divi = null)`; jede Komponente hat zusätzlich `problems: list`. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`: + +```php + $cap !== 'edit_global_library'); + + self::assertTrue($roles->allows('divi_library')); + self::assertNull($roles->refusal('divi_library')); + $refusal = $roles->refusal('edit_global_library'); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message()); + self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message()); + } + + public function testWithoutDiviNothingIsAllowed(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new DiviRoles())->allows('divi_library')); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`: + +```php + [ + 'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + 'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])], + ], + 1055 => [ + 'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + ], + 1057 => [], + ]; + Functions\when('get_the_terms')->alias( + static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : '' + ); + } + + public function testGlobalScopeAndLayoutTypeFromTheTerms(): void + { + $store = new DiviLibraryStore(); + + self::assertTrue($store->isGlobal(1058)); + self::assertFalse($store->isGlobal(1055)); + self::assertSame('section', $store->layoutType(1058)); + self::assertNull($store->layoutType(1057)); + self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category')); + } + + public function testDescribesAnEntry(): void + { + $post = new WP_Post([ + 'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul', + 'post_modified_gmt' => '2026-09-26 20:56:29', + ]); + + self::assertSame( + [ + 'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal', + 'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29', + ], + (new DiviLibraryStore())->describe($post) + ); + } + + public function testFindQueriesPublishedEntriesWithFilters(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find( + ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'], + 20, + 40 + ); + + self::assertSame('et_pb_layout', $captured['post_type']); + self::assertSame('publish', $captured['post_status']); + self::assertSame(21, $captured['posts_per_page']); + self::assertSame(40, $captured['offset']); + self::assertSame('Kopf', $captured['s']); + self::assertSame( + [ + 'relation' => 'AND', + ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']], + ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'], + ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']], + ], + $captured['tax_query'] + ); + } + + public function testReportsMissingTaxonomies(): void + { + Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); + + self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); + } +} +``` + +`tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`: + +```php + static fn (): bool => true, + 'B' => static fn (): bool => false, + 'C' => static function (): bool { + throw new RuntimeException('Divi changed'); + }, + ]); + + self::assertSame(['B', 'C'], $compatibility->problems()); + } + + public function testDefaultsCoverTheDiviInternalsInUse(): void + { + self::assertSame( + [ + 'ModuleRegistration (Modul-Katalog)', + 'DynamicContentOptions (Dynamic Content)', + 'et_pb_is_allowed (Divi-Rollen)', + 'Post-Typ et_pb_layout (Bibliothek)', + 'Taxonomien scope, layout_type, module_width (Bibliothek)', + 'GlobalLayout::is_global_layout_template (globale Module)', + 'ET_Core_PageResource (CSS-Cache)', + ], + array_keys(DiviCompatibility::defaults()) + ); + } +} +``` + +`tests/Unit/Integrations/Core/EnvironmentProbeTest.php`: + +```php +allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])); + $components = array_column($probe->components(), null, 'id'); + + self::assertSame(['GlobalLayout'], $components['divi']['problems']); + self::assertSame([], $components['pods']['problems']); + } +} +``` + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest'` +Expected: FAIL (Klassen fehlen) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/DiviRoles.php`: + +```php + 'Divi-Bibliothek', + 'save_library' => 'In der Bibliothek speichern', + 'add_library' => 'Aus der Bibliothek hinzufügen', + 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', + 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + ]; + + /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ + public function __construct(private ?Closure $check = null) + { + } + + public function allows(string $capability): bool + { + if ($this->check !== null) { + return ($this->check)($capability); + } + + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability); + } + + /** Null when the role allows $capability, else a forbidden error naming the option. */ + public function refusal(string $capability): ?WP_Error + { + if ($this->allows($capability)) { + return null; + } + + return Errors::forbidden( + sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } +} +``` + +`src/Integrations/Divi/Library/DiviLibraryStore.php`: + +```php +termSlugs($id, 'scope'), true); + } + + /** "layout", "section", "row" or "module"; null when the entry has no known type. */ + public function layoutType(int $id): ?string + { + foreach ($this->termSlugs($id, 'layout_type') as $slug) { + if (in_array($slug, self::LAYOUT_TYPES, true)) { + return $slug; + } + } + + return null; + } + + /** @return list */ + public function termSlugs(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy)); + } + + /** @return list */ + public function termNames(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy)); + } + + /** + * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). + * + * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters + * scope: "global" or "normal"; category: name of a layout_category term. + * @return list + */ + public function find(array $filters, int $limit, int $offset): array + { + $taxQuery = []; + if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) { + $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]]; + } + if (($filters['scope'] ?? '') === 'global') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']]; + } elseif (($filters['scope'] ?? '') === 'normal') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN']; + } + if (($filters['category'] ?? '') !== '') { + $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]]; + } + + $args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + if (($filters['search'] ?? '') !== '') { + $args['s'] = $filters['search']; + } + if ($taxQuery !== []) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. + $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery); + } + + return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post)); + } + + /** + * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string, + * categories: list, tags: list, modified: string} + */ + public function describe(WP_Post $post): array + { + $id = (int) $post->ID; + $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true); + + return [ + 'id' => $id, + 'title' => (string) $post->post_title, + 'layout_type' => $this->layoutType($id), + 'scope' => $this->isGlobal($id) ? 'global' : 'normal', + 'module_type' => $moduleType === '' ? null : $moduleType, + 'categories' => $this->termNames($id, 'layout_category'), + 'tags' => $this->termNames($id, 'layout_tag'), + 'modified' => (string) $post->post_modified_gmt, + ]; + } + + /** + * Library taxonomies not registered in this request (F2: Divi registers them in + * ET_Builder_Library; fixture finding 6: not in every context). + * + * @return list + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** @return list */ + private function terms(int $id, string $taxonomy): array + { + $terms = get_the_terms($id, $taxonomy); + + return is_array($terms) + ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term)) + : []; + } +} +``` + +`DynamicContentMap`: `private const REGISTRY` → `public const REGISTRY` (Docblock: „also checked by DiviCompatibility“). + +`src/Integrations/Divi/DiviCompatibility.php`: + +```php +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list Labels of the checks that failed or threw. */ + public function problems(): array + { + $problems = []; + foreach ($this->checks ?? self::defaults() as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $problems[] = (string) $label; + } + } + + return $problems; + } + + /** @return array */ + public static function defaults(): array + { + return [ + 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), + 'DynamicContentOptions (Dynamic Content)' => static fn (): bool => class_exists(DynamicContentMap::REGISTRY), + 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), + 'Post-Typ et_pb_layout (Bibliothek)' => static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), + 'Taxonomien scope, layout_type, module_width (Bibliothek)' => + static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], + 'GlobalLayout::is_global_layout_template (globale Module)' => + static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), + 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), + ]; + } +} +``` + +`EnvironmentProbe::components()` vollständig ersetzen (Konstruktor ergänzen, Import `use LIT\MCP\Integrations\Divi\DiviCompatibility;`): + +```php + public function __construct(private ?DiviCompatibility $divi = null) + { + } + + /** @return list}> */ + public function components(): array + { + $theme = wp_get_theme(); + $isDivi = $theme->get_template() === 'Divi'; + $parent = $theme->parent(); + $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null; + + $tecActive = class_exists('Tribe__Events__Main'); + + return [ + [ + 'id' => 'divi', + 'active' => $isDivi, + 'version' => $diviVersion, + 'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [], + ], + [ + 'id' => 'pods', + 'active' => defined('PODS_VERSION'), + 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + 'problems' => [], + ], + [ + 'id' => 'the-events-calendar', + 'active' => $tecActive, + 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, + 'problems' => [], + ], + ]; + } +``` + +In `SiteStatusAbility::description()` den Satz „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ ergänzen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest|SiteStatusAbilityTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library/DiviRoles.php src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/DiviCompatibility.php src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Core/EnvironmentProbe.php src/Integrations/Core/SiteStatusAbility.php tests/Unit/Integrations/Divi/Library tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Core/EnvironmentProbeTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: Divi adapters for roles and library with a live compatibility check`. + +--- + +### Task 7: Bausteine globaler Module im BlockTree – `copyAs`, `GlobalInstance`, `LibraryContent` + +Rein PHP, gegen die echten Builder-Fixtures. Befunde aus `tests/Fixtures/divi/stage3/`: (a) eine globale Instanz ist `divi/global-layout` mit `globalModule`, `blockName`, `localAttrs` (vollständiger Schnappschuss der Wurzel-Attribute, oberste Schlüssel sortiert – im Fixture `builderVersion, content, module` gegenüber `module, content, builderVersion` im Bibliothekseintrag), die Kinder mit ihren echten Blocknamen und `"globalParent":""` am Ende; eine Instanz ohne Kinder ist selbstschließend. (b) **Neu:** Bibliothekseinträge, die über Divis Dialog „Zur Bibliothek hinzufügen“ gespeichert wurden, haben zwischen den Blöcken `\r\n` (Formular-Übertragung des Browsers), Seiten `\n`; der Builder schreibt Instanzen mit `\n`. Deshalb normalisiert `LibraryContent::load()` Zeilenumbrüche im reinen Leerraum zwischen Blöcken. Mit beidem entsteht die Instanz byte-genau wie im Fixture (vorab per Skript geprüft). + +**Files:** +- Modify: `src/Integrations/Divi/BlockTree/Block.php` (`copyAs()`) +- Modify: `src/Integrations/Divi/BlockTree/Container.php` (`replaceChild()`, `unifyLineBreaks()`) +- Create: `src/Integrations/Divi/BlockTree/GlobalInstance.php` +- Create: `src/Integrations/Divi/BlockTree/LibraryContent.php` +- Modify: `src/Integrations/Divi/Compose/TreeEditor.php` (Wurzeltyp globaler Instanzen) +- Modify: `tests/Fixtures/divi/stage3/README.md` (Befund 9) +- Test: `tests/Unit/Integrations/Divi/BlockTree/BlockTest.php`, `…/ContainerTest.php`, `…/GlobalInstanceTest.php`, `…/LibraryContentTest.php`, `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php` + +**Interfaces:** +- Consumes: `Parser::parse()`, `Serializer::block()`, `NodeIndex::build()`, `DiviFixtures::markup()`. +- Produces: + - `Block::copyAs(string $name, array $attrs): Block` + - `Container::replaceChild(Block $old, Block $new): void`, `Container::unifyLineBreaks(): void` + - `GlobalInstance::BLOCK = 'divi/global-layout'`, `GlobalInstance::effectiveName(Block): string`, `GlobalInstance::of(Block $root, int $libraryId): Block`, `GlobalInstance::deglobalize(Block $block): Block` + - `LibraryContent::load(Parser $parser, string $content): Document` (wirft `MalformedMarkup`), `LibraryContent::wrap(Block $node): string`, `LibraryContent::roots(Document $document): list`, `LibraryContent::containsGlobal(Block $block): bool`, `LibraryContent::shapeViolation(list $before, list $after, ?string $layoutType, bool $global): ?string` + +- [ ] **Step 1: Failing tests schreiben** + +In `BlockTest`: + +```php + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame("\n\n", Serializer::block($row)); + self::assertFalse($row->copyAs('divi/row', [])->isOpaque()); + } + + public function testCopyAsRefusesInvalidNames(): void + { + $this->expectException(InvalidArgumentException::class); + + Block::create('divi/text')->copyAs('Divi Text', []); + } +``` + +In `ContainerTest`: + +```php + public function testReplaceChildKeepsTheTextAround(): void + { + $document = (new Parser())->parse("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } +``` + +(Imports `Block`, `Parser`, `Serializer`, `InvalidArgumentException` je nach Datei ergänzen.) + +`tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php`: + +```php +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + 'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'], + ]; + } + + /** @dataProvider instances */ + public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, '')) + ) + ); + } + + public function testContainsGlobal(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertTrue(LibraryContent::containsGlobal($index->find('s0'))); + self::assertFalse(LibraryContent::containsGlobal($index->find('s1'))); + } + + public function testShapeViolations(): void + { + $section = Block::create('divi/section'); + $row = Block::create('divi/row'); + $instance = GlobalInstance::of(Block::create('divi/text'), 9); + $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [ + Block::create('divi/column', [], [$instance]), + ])]); + + self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false)); + self::assertStringContainsString('nicht leer', (string) LibraryContent::shapeViolation([$section], [], 'section', false)); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$row], null, false) + ); + self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false)); + self::assertStringContainsString( + 'nur Abschnitte', + (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false) + ); + self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false)); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true) + ); + } +} +``` + +In `TreeEditorTest`: + +```php + /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */ + public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void + { + $document = $this->document('stage3/page--globale-instanzen.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1']))); + + $index = NodeIndex::build($document); + $module = GlobalInstance::of(Block::create('divi/text'), 1059); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module])); + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)]) + ); + } +``` + +(Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest'` +Expected: FAIL (Methoden/Klassen fehlen) + +- [ ] **Step 3: Implementieren** + +`Block.php` – neue Methode nach `copy()`: + +```php + /** + * A deep copy of this block under another name and attributes: opener and closer are + * regenerated, children and the text between them are kept, the void flag too. Opaque exactly + * when the Parser would make it so. GlobalInstance uses it to turn a library root into an + * instance and back. + * + * @param array $attrs + */ + public function copyAs(string $name, array $attrs): self + { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name)); + } + + $copy = clone $this; + $copy->name = $name; + $copy->attrs = self::deepCloneAttrs($attrs); + $copy->rawOpen = null; + $copy->rawClose = null; + $copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + + return $copy; + } +``` + +`Container.php` – neue Methoden nach `removeChild()`: + +```php + /** Puts $new where the child $old is; the text around it stays. */ + public function replaceChild(Block $old, Block $new): void + { + $this->guardMutable(); + $this->parts[$this->partPosition($old)] = $new; + $this->touched(); + } + + /** + * "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved + * through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture + * finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched. + */ + public function unifyLineBreaks(): void + { + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $part->unifyLineBreaks(); + } elseif (trim($part) === '' && str_contains($part, "\r\n")) { + $this->parts[$position] = str_replace("\r\n", "\n", $part); + } + } + } +``` + +`src/Integrations/Divi/BlockTree/GlobalInstance.php`: + +```php +name() !== self::BLOCK) { + return $block->name(); + } + + $name = $block->attrs()['blockName'] ?? ''; + + return is_string($name) ? $name : ''; + } + + /** + * The instance the Visual Builder writes for library entry $libraryId whose content root is + * $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested; + * fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and + * localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out + * when empty –, the root's children as snapshot with "globalParent": "" appended to every + * block below. A void root gives a void instance. $root is not changed. + */ + public static function of(Block $root, int $libraryId): Block + { + $id = (string) $libraryId; + $local = $root->attrs(); + ksort($local); + $attrs = ['globalModule' => $id, 'blockName' => $root->name()]; + if ($local !== []) { + $attrs['localAttrs'] = $local; + } + + $instance = $root->copyAs(self::BLOCK, $attrs); + foreach ($instance->children() as $child) { + self::markParent($child, $id); + } + + return $instance; + } + + /** + * Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1 + * PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every + * divi/global-layout at or below $block becomes its blockName with localAttrs as attributes, + * and globalParent disappears from every block. Other opaque blocks stay. Changes $block in + * place (pass a copy) and returns the result – a new block when $block itself was an instance. + */ + public static function deglobalize(Block $block): Block + { + if ($block->name() === self::BLOCK) { + $name = self::effectiveName($block); + if ($name === '') { + return $block; + } + $local = $block->attrs()['localAttrs'] ?? []; + $block = $block->copyAs($name, is_array($local) ? $local : []); + } + + if ($block->isOpaque()) { + return $block; + } + + if (array_key_exists('globalParent', $block->attrs())) { + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $block->setAttrs($attrs); + } + + foreach ($block->children() as $child) { + $plain = self::deglobalize($child); + if ($plain !== $child) { + $block->replaceChild($child, $plain); + } + } + + return $block; + } + + private static function markParent(Block $block, string $id): void + { + if ($block->isOpaque()) { + return; + } + + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $attrs['globalParent'] = $id; + $block->setAttrs($attrs); + foreach ($block->children() as $child) { + self::markParent($child, $id); + } + } +} +``` + +`src/Integrations/Divi/BlockTree/LibraryContent.php`: + +```php +parse($content); + $document->unifyLineBreaks(); + + return $document; + } + + /** post_content of a library entry holding $node, framed like the builder's "add to library". */ + public static function wrap(Block $node): string + { + return '' . Serializer::block($node) . ''; + } + + /** @return list Top-level nodes: the children of the first divi/placeholder, else of the document. */ + public static function roots(Document $document): array + { + foreach ($document->children() as $block) { + if ($block->isPlaceholder()) { + return $block->children(); + } + } + + return $document->children(); + } + + /** Whether $block is or contains a global module. */ + public static function containsGlobal(Block $block): bool + { + if ($block->isGlobal()) { + return true; + } + + foreach ($block->children() as $child) { + if (self::containsGlobal($child)) { + return true; + } + } + + return false; + } + + /** + * Why the roots $after may not replace $before, or null. Instances on pages expect their + * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the + * same type; a layout entry (or one with several roots) holds sections only. A global entry + * contains no global modules (F10: Divi forbids nesting). + * + * @param list $before + * @param list $after + */ + public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string + { + if ($after === []) { + return 'Der Bibliothekseintrag darf nicht leer werden.'; + } + + if ($layoutType === 'layout' || count($before) !== 1) { + foreach ($after as $root) { + if (GlobalInstance::effectiveName($root) !== 'divi/section') { + return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.'; + } + } + } else { + $expected = GlobalInstance::effectiveName($before[0]); + if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) { + return sprintf( + 'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).', + $expected + ); + } + } + + if ($global) { + foreach ($after as $root) { + if (self::containsGlobal($root)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung).'; + } + } + } + + return null; + } +} +``` + +`TreeEditor.php` – Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`; in `insert()`: + +```php + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); +``` + +und in `move()`: + +```php + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node)); +``` + +Klassen-Docblock von `TreeEditor` um „globale Instanzen gelten als ihr Wurzeltyp (blockName)“ ergänzen. + +`tests/Fixtures/divi/stage3/README.md` – unter „Format-Befunde“ als Punkt 9 anfügen: + +```markdown +9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`. +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest|ParserTest|SerializerTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/BlockTree src/Integrations/Divi/Compose/TreeEditor.php tests/Unit/Integrations/Divi/BlockTree tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Fixtures/divi/stage3/README.md +``` +Commit wie in den Global Constraints mit der Nachricht `feat: global instance in the builder's format, deglobalize, library content rules`. + +--- + +### Task 8: Bibliothekseinträge mit den Divi-Werkzeugen lesen – `LibraryAccess`, `DiviPageLoader` + +E1/E3: `lit-divi-outline` und `lit-divi-node-get` nehmen die ID eines Bibliothekseintrags an. `LibraryAccess::item()` prüft zuerst, ob die ID ein `et_pb_layout` ist; sonst geht es unverändert über `PostGuard` weiter (Seiten bleiben wie bisher, `ContentSchema::EXCLUDED` bleibt). Schreiben folgt in Task 9 – bis dahin laufen Schreibzugriffe auf Bibliotheks-IDs weiter über `PostGuard` und enden in `not_found`. + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryAccess.php` +- Modify: `src/Integrations/Divi/Pages/DiviPage.php` +- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Create: `tests/Support/LibraryFixtures.php` +- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `DiviLibraryStore`, `DiviRoles` (Task 6), `PostGuard::id()`, `Errors`. +- Produces: + - `class LibraryAccess { __construct(DiviLibraryStore $store, DiviRoles $roles, ?Closure $writable = null); store(): DiviLibraryStore; roles(): DiviRoles; item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null; canList(): ?WP_Error; canRead(WP_Post $item): ?WP_Error; isGlobal(WP_Post $item): bool }` (nicht final; `canEdit`, `canSave`, `canInsert` folgen in Task 9/12/13) + - `DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false)`, `DiviPage::isLibraryItem(): bool`; `editUrl()` liefert für Bibliothekseinträge `get_edit_post_link($id, 'raw')`. + - `DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null)`, `DiviPageLoader::library(): ?LibraryAccess`. + - `LibraryFixtures` (Tests): `ITEMS`, `item(int $id, array $fields = []): WP_Post`, `page(string $content, array $fields = []): WP_Post`, `given(array $posts, array $denied = []): void`, `store(): DiviLibraryStore`, `roles(array $deniedRoles = []): DiviRoles`, `access(array $deniedRoles = [], bool $writable = true, ?DiviLibraryStore $store = null): LibraryAccess`, `loader(?LibraryAccess $access = null): DiviPageLoader`. + +- [ ] **Step 1: Test-Helfer und failing tests schreiben** + +`tests/Support/LibraryFixtures.php`: + +```php + ID => [fixture file, layout type, global]. */ + public const ITEMS = [ + 1054 => ['library--layout-ganze-seite.html', 'layout', false], + 1055 => ['library--library-abschnitt.html', 'section', false], + 1056 => ['library--library-zeile.html', 'row', false], + 1057 => ['library--library-modul.html', 'module', false], + 1058 => ['library-global--globaler-abschnitt.html', 'section', true], + 1059 => ['library-global--globales-modul.html', 'module', true], + ]; + + /** @param array $fields */ + public static function item(int $id, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => DiviLibraryStore::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => '[Fixture] ' . $id, + 'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]), + ], $fields)); + } + + /** @param array $fields */ + public static function page(string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => self::PAGE, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Start', + 'post_content' => $content, + ], $fields)); + } + + /** + * WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied. + * + * @param list $posts + * @param list $denied + */ + public static function given(array $posts, array $denied = []): void + { + $byId = []; + foreach ($posts as $post) { + $byId[(int) $post->ID] = $post; + } + Functions\when('get_post')->alias( + static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true) + ); + Functions\when('get_post_type_object')->justReturn(null); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf( + 'https://kunde.test/wp-admin/post.php?post=%d&action=edit', + $post instanceof WP_Post ? $post->ID : (int) $post + )); + Functions\when('add_query_arg')->alias( + static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value + ); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + } + + /** A store that answers isGlobal()/layoutType() from ITEMS (no database). */ + public static function store(): DiviLibraryStore + { + $store = Mockery::mock(DiviLibraryStore::class)->makePartial(); + $store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false); + $store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null); + + return $store; + } + + /** @param list $deniedRoles Divi role options the user lacks. */ + public static function roles(array $deniedRoles = []): DiviRoles + { + return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true)); + } + + /** @param list $deniedRoles */ + public static function access( + array $deniedRoles = [], + bool $writable = true, + ?DiviLibraryStore $store = null + ): LibraryAccess { + return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); + } + + public static function loader(?LibraryAccess $access = null): DiviPageLoader + { + return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`: + +```php +item(1055, false)); + self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business'); + self::assertNull($access->item(9999, false)); + self::assertNull($access->item('abc', false)); + self::assertNull($access->canList()); + } + + public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void + { + $item = LibraryFixtures::item(1055); + + LibraryFixtures::given([$item], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item]); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item], ['edit_post']); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message()); + } + + public function testTrashedItemsCanBeRead(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'trash']); + LibraryFixtures::given([$item]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false)); + } + + public function testGlobalFromTheStore(): void + { + $access = LibraryFixtures::access(); + + self::assertTrue($access->isGlobal(LibraryFixtures::item(1058))); + self::assertFalse($access->isGlobal(LibraryFixtures::item(1055))); + } +} +``` + +In `DiviPageLoaderTest`: + +```php + public function testLoadsALibraryItemForReading(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $page = LibraryFixtures::loader()->load(1058, false); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + self::assertSame('divi/section', $page->index->find('s0')?->name()); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl()); + } + + public function testLibraryItemsNeedTheLibraryAccess(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); + self::assertToolError( + (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), + 'forbidden' + ); + } +``` + +(Imports: `LibraryFixtures`, `ContentSchema`, `Parser`, `Mockery`.) + +In `DiviReadAbilitiesTest`: + +```php + public function testOutlineOfALibraryItem(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1059]); + + self::assertSame(['m0'], array_column($result['nodes'], 'id')); + self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); + } +``` + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest'` +Expected: FAIL (`LibraryAccess` fehlt) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/LibraryAccess.php`: + +```php +store; + } + + public function roles(): DiviRoles + { + return $this->roles; + } + + /** + * The library entry $postId after the checks for reading, or their refusal; null when $postId + * names no library entry (the caller goes on with PostGuard). + */ + public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null + { + $id = PostGuard::id($postId); + $post = $id === null ? null : get_post($id); + if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) { + return null; + } + + return $this->canRead($post) ?? $post; + } + + /** Listing the library: edit_posts and the Divi role option divi_library. */ + public function canList(): ?WP_Error + { + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.'); + } + + return $this->roles->refusal('divi_library'); + } + + /** Reading one entry: canList() and edit_post on the entry. */ + public function canRead(WP_Post $item): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + return current_user_can('edit_post', (int) $item->ID) + ? null + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); + } + + public function isGlobal(WP_Post $item): bool + { + return $this->store->isGlobal((int) $item->ID); + } +} +``` + +(`$forWrite` wird in Task 9 ausgewertet; bis dahin ruft `DiviPageLoader` `item()` nur zum Lesen auf.) + +`DiviPage.php` – Konstruktor und `editUrl()`: + +```php + public function __construct( + public readonly WP_Post $post, + public readonly Document $document, + public readonly NodeIndex $index, + private bool $libraryItem = false + ) { + } + + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ + public function isLibraryItem(): bool + { + return $this->libraryItem; + } + + public function editUrl(): string + { + // A library entry has no public permalink (public: false); Divi's library list opens it here. + return $this->libraryItem + ? (string) get_edit_post_link($this->post, 'raw') + : self::builderUrl($this->post); + } +``` + +`DiviPageLoader.php` – Konstruktor, `library()` und Anfang von `load()` (Import `use LIT\MCP\Integrations\Divi\Library\LibraryAccess;`, `use WP_Post;`): + +```php + public function __construct( + private ContentSchema $schema, + private Parser $parser = new Parser(), + private ?LibraryAccess $library = null + ) { + } + + /** The library access the Divi tools use, null when library entries are not reachable. */ + public function library(): ?LibraryAccess + { + return $this->library; + } + + /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ + public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error + { + // Library entries (E1): only for reading so far, and only with a library access. + $item = $forWrite ? null : $this->library?->item($postId, false); + $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + if ($post instanceof WP_Error) { + return $post; + } +``` + +und am Ende von `load()`: + +```php + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); +``` + +`DiviIntegration.php` – Bibliothekszugang als Feld (Imports `DiviLibraryStore`, `DiviRoles`, `LibraryAccess`, `Parser`): + +```php + private LibraryAccess $access; + + public function __construct(private ContentSchema $schema, private ChangeLog $log) + { + $this->modules = new DiviModules(); + $this->design = new DesignSystemReader(); + $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles()); + } +``` + +und in `abilities()`: + +```php + $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages/DiviPage.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/DiviIntegration.php tests/Support/LibraryFixtures.php tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: Divi read tools accept library entries (library access, E1/E3)`. + +--- + +### Task 9: Bibliothekseinträge schreiben – Formregeln, globale Einträge mit Probelauf, `confirm` und Sicherung + +E2 und Antwort F2: Die Divi-Schreibwerkzeuge (`lit-divi-insert`, `-update`, `-move`, `-delete`, `lit/divi-duplicate-node`) ändern Bibliothekseinträge wie Seiten. Zusätzlich (`GlobalEdits`, aufgerufen aus `DiviWriter::commit()` nach der vollständig validierten Änderung): Der Eintrag behält seine Form (`LibraryContent::shapeViolation`); ist er global, wird ohne `confirm: true` nichts gespeichert und die Antwort nennt die betroffenen Seiten (Probelauf, `GlobalUsages` sucht `"globalModule":""` wie Divi); mit `confirm: true` sichert `DiviSaver` wie immer eine Revision im kses-Fenster, bei abgeschalteten Revisionen legt `GlobalEdits` vorher eine Sicherung an (`PostCloner::backup`), danach leert `DiviSaver` seitenweit den CSS- und Theme-Builder-Cache (`SaveScope::GlobalItem`). Bibliothekseinträge bekommen keine Divi-Flags (Fixture-Befund 4). Das Gruppen-Gate: ist „Divi-Bibliothek“ auf „nur lesen“ oder deaktiviert, sind Bibliothekseinträge für alle Divi-Werkzeuge nur lesbar. + +**Files:** +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`item()` für Schreiben, `canEdit()`, `writableGate()`, `GROUP`) +- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (Schreiben, `writable()`) +- Create: `src/Integrations/Divi/Pages/SaveScope.php` +- Modify: `src/Integrations/Divi/Pages/DiviSaver.php` +- Create: `src/Integrations/Divi/Library/GlobalUsages.php` +- Create: `src/Integrations/Divi/Library/GlobalEdits.php` +- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (`save()`-Scope, `commit()`, `notice()`) +- Modify: `src/Integrations/Divi/Pages/DiviWriteAbility.php` (`precheck()`, `commit`, `notice`) +- Modify: `src/Integrations/Divi/Pages/DiviSchema.php` (`confirm`) +- Modify: `src/Abilities/ExposurePolicy.php` (`groupWritable()`) +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php` +- Create: `tests/Stubs/EtCorePageResource.php`; Modify: `tests/bootstrap.php`, `tests/Support/DiviWriterFixture.php` +- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `…/Library/GlobalUsagesTest.php`, `…/Library/GlobalEditsTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `…/Pages/DiviSaverTest.php`, `tests/Unit/Abilities/ExposurePolicyTest.php` + +**Interfaces:** +- Consumes: `LibraryContent::load/roots/shapeViolation` (Task 7), `LibraryAccess`, `LibraryFixtures` (Task 8), `PostCloner::backup(WP_Post): int`, `Errors::confirmRequired/clientMessage/codeOf/unsupported/invalidInput`. +- Produces: + - `LibraryAccess::GROUP = 'divi-library'`, `LibraryAccess::canEdit(WP_Post $item): ?WP_Error`, `LibraryAccess::writableGate(): ?WP_Error`; `item($id, true)` prüft `canEdit()`. + - `enum SaveScope { Page, LibraryItem, GlobalItem; setsFlags(): bool; clearsSiteCss(): bool }` + - `DiviSaver::save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array`, `DiviSaver::saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): list` + - `class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list, count: int} }` (nicht final) + - `final class GlobalEdits { __construct(LibraryAccess $access, GlobalUsages $usages, ?Closure $backup = null, Parser $parser = new Parser()); prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array}|WP_Error; notice(DiviPage $page, WP_Error $error): WP_Error }` + - `DiviWriter::__construct(…, DiviComposition $composition, ?GlobalEdits $globals = null)`, `DiviWriter::save(…, SaveScope $scope = SaveScope::Page)`, `DiviWriter::commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error`, `DiviWriter::notice(DiviPage $page, WP_Error $error): WP_Error` + - `DiviWriteAbility::precheck(array $input): ?WP_Error` (protected, Standard `null`) + - `ExposurePolicy::groupWritable(string $group): bool` + - `DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?Closure $libraryWritable = null)` + - `DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null)` + +- [ ] **Step 1: Stub und failing tests schreiben** + +`tests/Stubs/EtCorePageResource.php`: + +```php + */ + public static array $removed = []; + + public static function remove_static_resources($postId, $owner, $force = false): void + { + self::$removed[] = [$postId, $owner, (bool) $force]; + } + } +} +``` + +In `tests/bootstrap.php` nach `RequestsException.php` ergänzen: `require_once __DIR__ . '/Stubs/EtCorePageResource.php';` + +`DiviWriterFixture::writer()` um den Parameter `?GlobalEdits $globals = null` (letzter Parameter, Import `LIT\MCP\Integrations\Divi\Library\GlobalEdits`) erweitern und an `new DiviWriter(…, self::composition($design, $unfiltered), $globals)` durchreichen. + +In `DiviSaverTest`: + +```php + public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void + { + \ET_Core_PageResource::$removed = []; + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + $cleared = []; + Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void { + $cleared[] = $what; + }); + + (new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame([], $cleared); + + \ET_Core_PageResource::$removed = []; + (new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem); + self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed); + self::assertSame(['all'], $cleared); + } + + public function testSaveNewOfALibraryItemSetsNoFlags(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU'])); + + self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem)); + self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:'))); + } +``` + +(Import `use LIT\MCP\Integrations\Divi\Pages\SaveScope;`.) + +In `LibraryAccessTest`: + +```php + public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void + { + LibraryFixtures::given([ + LibraryFixtures::item(1057), + LibraryFixtures::item(1059), + LibraryFixtures::item(1055, ['post_status' => 'trash']), + ]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true)); + self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden'); + $readonly = LibraryFixtures::access([], false)->item(1057, true); + self::assertToolError($readonly, 'forbidden'); + self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message()); + self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true)); + self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden'); + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true)); + } +``` + +In `DiviPageLoaderTest`: + +```php + public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access()); + + $page = $loader->loadWritable(1057); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + } +``` + +In `ExposurePolicyTest`: + +```php + public function testGroupWritable(): void + { + self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library')); + } +``` + +`tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php`: + +```php +makePartial(); + $queries = []; + $db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string { + $queries[] = $sql; + + return '3'; + }); + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { + $queries[] = $sql; + + return [ + ['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'], + ['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'], + ]; + }); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061); + + $usages = (new GlobalUsages($db))->of(1058); + + self::assertSame(3, $usages['count']); + self::assertSame( + [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'], + ['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'], + ], + $usages['items'] + ); + self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); + self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]); + self::assertStringContainsString('LIMIT 50', $queries[1]); + } +} +``` + +`tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php`: + +```php + Content and scope handed to the saver. */ + private array $saved = []; + + private int $backups = 0; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_revisions_enabled')->justReturn(true); + } + + private function usages(): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn([ + 'items' => [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], + ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], + ], + 'count' => 2, + ]); + + return $usages; + } + + /** @param list $deniedRoles */ + private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $this->saved[] = [$content, $scope]; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $access = LibraryFixtures::access($deniedRoles, $writable); + $design = DiviWriterFixture::designReader(); + $globals = new GlobalEdits($access, $this->usages(), function (WP_Post $item): int { + ++$this->backups; + + return 1071; + }); + + return new DiviWriter( + LibraryFixtures::loader($access), + $saver, + DiviWriterFixture::log(), + $design, + DiviWriterFixture::composition($design), + $globals + ); + } + + private static function rev(int $id): string + { + return Rev::of((string) LibraryFixtures::item($id)->post_content); + } + + /** @return array */ + private static function relabel(int $id, bool $confirm = false): array + { + return array_merge( + ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]], + $confirm ? ['confirm' => true] : [] + ); + } + + public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + + $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + + self::assertTrue($result['ok']); + self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]); + self::assertStringStartsWith(''; + + self::assertSame([], self::guard()->problems($index->find('s0'))); + self::assertSame([], self::guard()->problems($index->find('g0'))); + self::assertSame([], self::guard()->problems(self::block($link))); + } + + /** @return array */ + public static function refused(): array + { + $ref = static fn (string $name, string $settings): string => '"$variable({"type":"content",' + . '"value":{"name":"' . $name . '","settings":' . $settings . '}})$"'; + + return [ + 'custom css' => [ + '', + '"css"', + ], + 'html attributes' => [ + '', + 'module.decoration.attributes', + ], + 'code module' => ['', 'Code-Modul'], + 'script in before' => [ + '', + 'nicht schreiben darf', + ], + 'protected meta' => [ + '', + 'custom_meta__edit_lock', + ], + 'css in the local attributes of an instance' => [ + '', + '"css"', + ], + 'code module deep inside a section' => [ + '' + . '', + 'Code-Modul', + ], + ]; + } + + /** @dataProvider refused */ + public function testRefusesBuilderOnlyValuesForUsersWithoutUnfilteredHtml(string $markup, string $problem): void + { + $problems = self::guard()->problems(self::block($markup)); + + self::assertNotSame([], $problems); + self::assertStringContainsString($problem, implode(' | ', $problems)); + self::assertSame([], self::guard(true)->problems(self::block($markup)), 'unfiltered_html may carry it'); + } +} +``` + +In `DiviCompositionTest` ergänzen: + +```php + public function testStoredGuardSharesTheCleanerAndReferences(): void + { + $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); + + self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); + self::assertSame( + [], + $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) + ); + } +``` + +(Imports `StoredNodeGuard`, `Block`, `DiviWriterFixture`.) + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest'` +Expected: FAIL (`StoredNodeGuard` fehlt) + +- [ ] **Step 3: Implementieren** + +`VariableRef.php` – nach `typeOf()`: + +```php + /** + * The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares + * a stored reference with canonical(): different settings mean cleaning would change them. + * + * @return array + */ + public static function settingsOf(string $value): array + { + try { + $payload = self::payloadOf($value); + } catch (InvalidArgumentException) { + return []; + } + + $settings = $payload['value']['settings'] ?? []; + + return is_array($settings) ? $settings : []; + } +``` + +`src/Integrations/Divi/Compose/StoredNodeGuard.php`: + +```php + German problems; [] when $root may move to the target post. */ + public function problems(Block $root): array + { + if ($this->sanitizer->allowsUnfilteredHtml()) { + return []; + } + + $problems = []; + $this->walk($root, $problems); + + return array_values(array_unique($problems)); + } + + /** @param list $problems */ + private function walk(Block $block, array &$problems): void + { + $module = GlobalInstance::effectiveName($block); + if (in_array($module, self::CODE_MODULES, true)) { + $problems[] = sprintf( + '%s: Code-Modul – nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html).', + $module + ); + } + + $attrs = $block->attrs(); + if ($block->name() === GlobalInstance::BLOCK) { + $attrs = is_array($attrs['localAttrs'] ?? null) ? $attrs['localAttrs'] : []; + } + + foreach (AttrMap::flatten($attrs) as $path => $value) { + try { + $this->cleaner->guardPath((string) $path); + } catch (InvalidArgumentException $e) { + $problems[] = $e->getMessage(); + continue; + } + $this->references($module, (string) $path, $value, null, $problems); + } + + foreach ($block->children() as $child) { + $this->walk($child, $problems); + } + } + + /** @param list $problems */ + private function references(string $module, string $path, mixed $value, ?string $key, array &$problems): void + { + if (is_array($value)) { + foreach ($value as $childKey => $item) { + $isField = is_string($childKey) + && ! in_array($childKey, AttrMap::BREAKPOINTS, true) + && ! in_array($childKey, self::STATE_KEYS, true); + $this->references($module, $path, $item, $isField ? $childKey : $key, $problems); + } + + return; + } + + if (! is_string($value) || ! VariableRef::contains($value)) { + return; + } + + try { + $url = $this->cleaner->kindOf($module, $path, $key) === ValueKind::Url; + $canonical = $this->references->canonical($value, $this->sanitizer, $url); + } catch (InvalidArgumentException $e) { + $problems[] = sprintf('%s: %s', $path, $e->getMessage()); + + return; + } + + if (self::normalized(VariableRef::settingsOf($canonical)) !== self::normalized(VariableRef::settingsOf($value))) { + $problems[] = sprintf( + '%s: Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – nur im Divi-Builder ' + . 'übertragbar.', + $path + ); + } + } + + /** + * @param array $settings + * @return array Keys sorted, scalars as strings (post_id 839 = "839"). + */ + private static function normalized(array $settings): array + { + ksort($settings); + + return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); + } +} +``` + +`DiviComposition` (Import `StoredNodeGuard`): + +```php + /** E6: the check for stored nodes that move into this composition's entry. */ + public function storedGuard(): StoredNodeGuard + { + return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer); + } +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest|VariableRefTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Compose/StoredNodeGuard.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)`. + +--- + +### Task 12: `lit/library-save` – Knoten als Bibliothekseintrag (E4) + +E4: Das Plugin legt Einträge selbst an – kein `et_pb_submit_layout` (im REST-Kontext nicht geladen, speichert mit kses, F2), keine Builder-Route (Nonce, Preset-Import, F3). `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Bibliotheksentwürfe), Terme und Meta wie `et_pb_submit_layout` (verglichen mit den echten Builder-Einträgen der Fixtures; `_et_pb_row_layout` wird nicht geschrieben – Divi selbst speichert dort `"[object Object]"`, Befund 5), danach der Inhalt über `DiviSaver::saveNew` (`SaveScope::LibraryItem`, ohne Divi-Flags, Befund 4) als `divi/placeholder`-Rahmen um die unveränderten Bytes des Knotens. Alles oder nichts: jeder Fehler löscht den neuen Eintrag endgültig. Der Knoten auf der Seite bleibt unverändert (das Verknüpfen ist `lit/library-make-global`, Etappe 3b). + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryItemSpec.php` +- Modify: `src/Integrations/Divi/Library/DiviLibraryStore.php` (`create()`, `delete()`) +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canSave()`) +- Create: `src/Integrations/Divi/Library/LibrarySaveAbility.php` +- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`, `…/DiviLibraryStoreTest.php`, `…/LibraryAccessTest.php`, `…/LibrarySaveAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `LibraryContent::wrap/containsGlobal`, `NodeSummary::globalRef`, `ModuleCatalog::has/category`, `DiviSaver::saveNew(…, SaveScope::LibraryItem)`, `StoredNodeGuard` über `DiviWriter::composition($page)->storedGuard()`, `PostSaveFollowUp`, `Errors::writeFailed/partialWrite/revMismatch`. +- Produces: + - `final class LibraryItemSpec { __construct(string $title, string $layoutType, bool $global, string $moduleWidth, ?string $moduleType, string $builtFor, array $categories = [], array $tags = []); static forNode(Block $node, ModuleCatalog $catalog, string $title, bool $global, string $builtFor): self|string; withTerms(array $categories, array $tags): self; terms(): array>; meta(): array }` + - `DiviLibraryStore::create(LibraryItemSpec $spec): int` (wirft `RuntimeException`, löscht bei Teilfehlern), `DiviLibraryStore::delete(int $id): bool` + - `LibraryAccess::canSave(): ?WP_Error` + - `final class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) }` – `lit/library-save`, indirekt, schreibend (nicht idempotent); Ergebnis `ToolResult` + `library_id`, `tree_rev`. + - `LibraryIntegration::__construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules)` + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`: + +```php +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))) + ->find($id); + } + + private static function libraryRoot(string $file): Block + { + return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0]; + } + + /** @return array Fixture role => [node source, global]. */ + public static function builderEntries(): array + { + return [ + 'library--library-abschnitt' => ['page:s1', false], + 'library--library-zeile' => ['page:s1.r0', false], + 'library--library-modul' => ['page:s1.r0.c0.m0', false], + 'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true], + 'library-global--globales-modul' => ['library:library-global--globales-modul.html', true], + ]; + } + + /** + * Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry), + * without Divi's broken _et_pb_row_layout ("[object Object]", finding 5). + * + * @dataProvider builderEntries + */ + public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void + { + [$kind, $ref] = explode(':', $source, 2); + $node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref); + $role = (string) $this->dataName(); + $json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true); + + $spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page'); + + self::assertInstanceOf(LibraryItemSpec::class, $spec); + $expectedTerms = []; + foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) { + $expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug'); + } + self::assertEquals($expectedTerms, $spec->terms()); + $expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']); + unset($expectedMeta['_et_pb_row_layout']); + self::assertEquals($expectedMeta, $spec->meta()); + } + + public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void + { + $catalog = DiviFixtures::catalog(); + + self::assertStringContainsString( + 'bereits ein globales Modul (Bibliothekseintrag 1058)', + (string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'nur Abschnitte, Zeilen und Module', + (string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page') + ); + self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page')); + } + + public function testFullwidthAndTerms(): void + { + $section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]); + + $spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page'); + + self::assertSame(['fullwidth'], $spec->terms()['module_width']); + self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']); + self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms()); + } +} +``` + +In `DiviLibraryStoreTest`: + +```php + public function testCreatesAPublishedEntryWithTermsAndMeta(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + $inserted = []; + Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int { + $inserted = $data; + + return 2001; + }); + $terms = []; + Functions\when('wp_set_object_terms')->alias(static function (int $id, array $values, string $taxonomy) use (&$terms): array { + $terms[$taxonomy] = $values; + + return [1]; + }); + $meta = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool { + $meta[$key] = $value; + + return true; + }); + Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $meta[$key] ?? ''); + $spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page'); + + self::assertSame(2001, (new DiviLibraryStore())->create($spec)); + self::assertSame( + ['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''], + $inserted + ); + self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms); + self::assertSame('[]', $meta['_et_pb_excluded_global_options']); + } + + public function testAFailedStepDeletesTheNewEntry(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.')); + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001])); + + $this->expectException(\RuntimeException::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page')); + } + + public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void + { + Functions\when('taxonomy_exists')->justReturn(false); + Functions\expect('wp_insert_post')->never(); + + $this->expectException(\RuntimeException::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page')); + } +``` + +(Import `LibraryItemSpec`. Die Stubs aus `setUp()` für `get_post_meta` werden im ersten Test per `Functions\when` überschrieben.) + +In `LibraryAccessTest`: + +```php + public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void + { + LibraryFixtures::given([]); + self::assertNull(LibraryFixtures::access()->canSave()); + + LibraryFixtures::given([], ['publish_posts']); + self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden'); + + LibraryFixtures::given([]); + self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden'); + } +``` + +`tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php`: + +```php + */ + private array $specs = []; + + /** @var list */ + private array $contents = []; + + private int $deleted = 0; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + LibraryFixtures::given([$this->page]); + Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture' + ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy]) + : false); + $this->log = new FakeChangeLog(4711); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility + { + $store = LibraryFixtures::store(); + $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { + $this->specs[] = $spec; + + return 2001; + }); + $store->allows('delete')->andReturnUsing(function (int $id): bool { + ++$this->deleted; + + return true; + }); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array { + if ($layoutFailure !== null) { + throw $layoutFailure; + } + $this->contents[] = [$id, $content, $scope]; + LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]); + + return []; + }); + $access = LibraryFixtures::access($deniedRoles, $writable, $store); + $saved = []; + $failures = []; + + return new LibrarySaveAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::writer($saved, $failures, false, LibraryFixtures::loader($access)), + $saver, + $this->log, + DiviWriterFixture::modules() + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge( + ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], + $extra + ); + } + + public function testSavesASectionLikeTheBuilder(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Fixture']])); + + self::assertTrue($result['ok']); + self::assertSame(2001, $result['library_id']); + self::assertSame('section', $this->specs[0]->layoutType); + self::assertSame([26], $this->specs[0]->terms()['layout_category']); + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), + $this->contents[0][1] + ); + self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]); + self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']); + self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); + self::assertSame('2001', $this->log->records[0]['object_id']); + } + + public function testSavesAGlobalModule(): void + { + $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global'])); + + self::assertTrue($result['ok']); + self::assertTrue($this->specs[0]->global); + self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']); + self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']); + } + + public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testStaleSourceTreeRevCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch'); + self::assertSame([], $this->specs); + } + + public function testNeedsPublishPostsAndTheDiviRole(): void + { + self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden'); + LibraryFixtures::given([$this->page], ['publish_posts']); + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertSame([], $this->specs); + } + + public function testEditorCannotSaveACodeModule(): void + { + $this->page = LibraryFixtures::page( + '' + . '' + . '' + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result))); + self::assertSame([], $this->specs); + } + + public function testUnknownCategoryCreatesNothing(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testFailedLayoutWriteDeletesTheEntry(): void + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + } finally { + ini_set('error_log', (string) $previous); + @unlink($log); + } + + self::assertToolError($result, 'write_failed'); + self::assertSame(1, $this->deleted); + } +} +``` + +In `DiviIntegrationTest::testProvidesTheLibraryGroup` die erwartete Liste auf `['lit/library-list', 'lit/library-save']` erweitern. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest'` +Expected: FAIL (`LibraryItemSpec`, `create`, `canSave`, `LibrarySaveAbility` fehlen) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Library/LibraryItemSpec.php`: + +```php + $categories layout_category term IDs. + * @param list $tags layout_tag term IDs. + */ + public function __construct( + public readonly string $title, + public readonly string $layoutType, + public readonly bool $global, + public readonly string $moduleWidth, + public readonly ?string $moduleType, + public readonly string $builtFor, + public readonly array $categories = [], + public readonly array $tags = [] + ) { + } + + /** + * The entry that holds $node, or the German reason it cannot be saved: sections, rows and + * modules only (Divi's "add to library"); a global module is already an entry; a global entry + * holds no global module (F10). + */ + public static function forNode( + Block $node, + ModuleCatalog $catalog, + string $title, + bool $global, + string $builtFor + ): self|string { + $ref = NodeSummary::globalRef($node); + if ($ref !== null) { + return sprintf( + 'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert ' + . 'einfügen statt erneut speichern.', + $ref['id'] + ); + } + + $name = $node->name(); + if ($name === 'divi/section') { + $type = 'section'; + $width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular'; + } elseif ($name === 'divi/row') { + $type = 'row'; + $width = 'regular'; + } elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) { + $type = 'module'; + $width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular'; + } else { + return sprintf( + '%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).', + $name + ); + } + + if ($global && LibraryContent::containsGlobal($node)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung); als normalen Eintrag speichern.'; + } + + return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor); + } + + /** + * @param list $categories + * @param list $tags + */ + public function withTerms(array $categories, array $tags): self + { + return new self( + $this->title, + $this->layoutType, + $this->global, + $this->moduleWidth, + $this->moduleType, + $this->builtFor, + $categories, + $tags + ); + } + + /** @return array> Taxonomy => slugs (system terms) or term IDs. */ + public function terms(): array + { + $terms = [ + 'scope' => [$this->global ? 'global' : 'non_global'], + 'layout_type' => [$this->layoutType], + 'module_width' => [$this->moduleWidth], + ]; + if ($this->categories !== []) { + $terms['layout_category'] = $this->categories; + } + if ($this->tags !== []) { + $terms['layout_tag'] = $this->tags; + } + + return $terms; + } + + /** @return array */ + public function meta(): array + { + $meta = []; + if ($this->moduleType !== null) { + $meta['_et_pb_module_type'] = $this->moduleType; + } + if ($this->global && $this->layoutType === 'module') { + $meta['_et_pb_excluded_global_options'] = '[]'; + } + $meta['_et_pb_built_for_post_type'] = $this->builtFor; + $meta['_et_pb_template_type'] = $this->layoutType; + + return $meta; + } +} +``` + +`DiviLibraryStore` – neue Methoden (Imports `RuntimeException`, `WP_Error`): + +```php + /** + * A published entry with the terms and meta of $spec and empty content (E4; the layout follows + * through DiviSaver::saveNew()). All or nothing: when a term or meta write fails, the entry is + * deleted again before the exception leaves. + * + * @throws RuntimeException + */ + public function create(LibraryItemSpec $spec): int + { + $missing = $this->missingTaxonomies(); + if ($missing !== []) { + throw new RuntimeException('Divi Library taxonomies not registered: ' . implode(', ', $missing)); + } + + $id = wp_insert_post(wp_slash([ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $spec->title, + 'post_content' => '', + ]), true); + if ($id instanceof WP_Error || (int) $id <= 0) { + throw new RuntimeException( + 'wp_insert_post refused the library entry: ' . ($id instanceof WP_Error ? $id->get_error_message() : '0') + ); + } + + $id = (int) $id; + try { + foreach ($spec->terms() as $taxonomy => $terms) { + $result = wp_set_object_terms($id, $terms, $taxonomy); + if ($result instanceof WP_Error) { + throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message())); + } + } + foreach ($spec->meta() as $key => $value) { + update_post_meta($id, $key, $value); + if ((string) get_post_meta($id, $key, true) !== $value) { + throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id)); + } + } + } catch (RuntimeException $e) { + $this->delete($id); + throw $e; + } + + return $id; + } + + /** Deletes an entry for good (only this plugin's own, just created entries). */ + public function delete(int $id): bool + { + return (bool) wp_delete_post($id, true); + } +``` + +`LibraryAccess::canSave()`: + +```php + /** + * Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always + * published, F2), the role option save_library and the Tools-tab gate. + */ + public function canSave(): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + $type = get_post_type_object(DiviLibraryStore::POST_TYPE); + $publish = is_object($type) && isset($type->cap->publish_posts) ? (string) $type->cap->publish_posts : 'publish_posts'; + if (! current_user_can($publish)) { + return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).'); + } + + return $this->roles->refusal('save_library') ?? $this->writableGate(); + } +``` + +`src/Integrations/Divi/Library/LibrarySaveAbility.php`: + +```php + 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; + + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function name(): string + { + return 'lit/library-save'; + } + + public function label(): string + { + return 'In der Divi-Bibliothek speichern'; + } + + public function description(): string + { + return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – ' + . 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die ' + . 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit ' + . 'lit-library-insert (mode "global") auf Seiten einfügen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Seite (oder Eintrag) mit dem Knoten.'], + 'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'], + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'scope' => ['type' => 'string', 'enum' => ['normal', 'global'], 'description' => 'Standard: normal.'], + 'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'id', 'title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + ['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']], + ['library_id', 'tree_rev'] + ); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $refusal = $this->access->canSave(); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $refusal = $this->access->canSave(); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.', + DiviPageLoader::OUTLINE_HINT + ); + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + $node = $page->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $page->post); + } + + $title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : ''); + if ($title === '') { + return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']); + } + + $spec = LibraryItemSpec::forNode( + $node, + $this->modules->catalog(), + $title, + ($input['scope'] ?? 'normal') === 'global', + (string) $page->post->post_type + ); + if (is_string($spec)) { + return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]); + } + + $problems = $this->writer->composition($page)->storedGuard()->problems($node); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde angelegt.', + ['id' => implode('; ', $problems)] + ); + } + + $categories = $this->termIds('layout_category', $input['categories'] ?? []); + $tags = $this->termIds('layout_tag', $input['tags'] ?? []); + foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) { + if (is_string($ids)) { + return Errors::invalidInput('Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.', [ + $field => $ids, + ]); + } + } + + return $this->create($page, $spec->withTerms($categories, $tags), LibraryContent::wrap($node), $input); + } + + /** + * @param array $input + * @return array|WP_Error + */ + private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array|WP_Error + { + try { + $libraryId = $this->access->store()->create($spec); + } catch (RuntimeException $e) { + Log::failure('lit/library-save create', $e); + + return Errors::writeFailed('Der Bibliothekseintrag konnte nicht angelegt werden.'); + } + + try { + $warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem); + } catch (Throwable $e) { + Log::failure('lit/library-save layout', $e); + if (! $this->access->store()->delete($libraryId)) { + return Errors::partialWrite('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.', $libraryId); + } + + return Errors::writeFailed('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.'); + } + + $followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings)) + ->afterSave($input); + $created = $followUp->reread(); + $kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : ''); + $followUp->log( + DiviLibraryStore::POST_TYPE, + sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID) + ); + + $result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created) + ->with('library_id', $libraryId) + ->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content)) + ->urls(null, (string) get_edit_post_link($libraryId, 'raw')); + if ($spec->global) { + $result->warning( + 'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert ' + . '(mode "global") einfügen.' + ); + } + + return $result->toArray(); + } + + /** @return list|string Term IDs of existing terms by name, or the problem. */ + private function termIds(string $taxonomy, mixed $names): array|string + { + $ids = []; + foreach (is_array($names) ? $names : [] as $name) { + $term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false; + if (! $term instanceof WP_Term) { + return sprintf( + '„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).', + is_string($name) ? $name : '?' + ); + } + $ids[] = (int) $term->term_id; + } + + return $ids; + } +} +``` + +`LibraryIntegration` – Konstruktor und `abilities()` (Imports `ChangeLog`, `DiviSaver`, `DiviWriter`): + +```php + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DesignSystemReader $design, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function abilities(): array + { + return [ + new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()), + new LibrarySaveAbility($this->access, $this->loader, $this->writer, $this->saver, $this->log, $this->modules), + ]; + } +``` + +`DiviIntegration` – Writer-Fabrik teilen und `library()` anpassen: + +```php + public function library(): LibraryIntegration + { + $loader = $this->loader(); + + return new LibraryIntegration( + $this->access, + $loader, + $this->design, + $this->writer($loader), + new DiviSaver(), + $this->log, + $this->modules + ); + } + + private function writer(DiviPageLoader $loader): DiviWriter + { + return new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + new DiviComposition($this->modules, $this->design), + new GlobalEdits($this->access, new GlobalUsages()) + ); + } +``` + +In `abilities()` `$writer = $this->writer($loader);` statt des bisherigen `new DiviWriter(…)`. + +`docs/guides/overview.md`: „Divi-Bibliothek (`lit/library-list`)“ → „Divi-Bibliothek (`lit/library-list`, `lit/library-save`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/library-save creates library entries like Divi (E4)`. + +--- + +### Task 13: `lit/library-insert` – Kopie oder globale Instanz im Builder-Format (E5, direkt) + +Das einzige neue direkte Tool (22 direkte Tools). `mode` = `global` (Standard für globale Einträge) fügt die Instanz genau so ein, wie der Visual Builder sie schreibt (`GlobalInstance::of`, byte-gleich zum Fixture) – den Inhalt liefert Divi beim Rendern aus der Bibliothek (F7). `mode` = `copy` (Standard sonst) fügt die Wurzeln des Eintrags ohne `divi/placeholder` ein; bei globalen Einträgen vorher entglobalisiert wie Divis `/load` (`GlobalInstance::deglobalize`, F10). Rechte: Ziel wie `lit-divi-insert` (gleiche Sperre, alles oder nichts), dazu `add_library` (E3) und – Audit M2 – nur solange `lit/divi-insert` nicht gesperrt ist. Übernommene Knoten laufen durch `StoredNodeGuard`. + +**Files:** +- Create: `src/Integrations/Divi/Library/LibraryInsertAbility.php` +- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canInsert()`) +- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php` +- Modify: `docs/guides/overview.md` („Direkt sichtbar:“, Ruling Q2) +- Test: `tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`, `…/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriteAbility` (Task 9: `precheck`, `commit`), `GlobalInstance::of/deglobalize`, `LibraryContent::load/roots`, `DiviComposition::storedGuard/editor`, `TreeEditor::position/insert`, `DiviSchema::write/position`. +- Produces: `LibraryAccess::canInsert(): ?WP_Error`; `final class LibraryInsertAbility extends DiviWriteAbility { __construct(DiviWriter $writer, LibraryAccess $access, ?Closure $diviWritable = null, Parser $parser = new Parser()) }` – `lit/library-insert`, direkt; `LibraryIntegration::__construct(…, DiviModules $modules, ?Closure $diviWritable = null)`; `DiviIntegration::library(?Closure $diviWritable = null)`. + +- [ ] **Step 1: Failing tests schreiben** + +In `LibraryAccessTest`: + +```php + public function testInsertingNeedsAddLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access()->canInsert()); + self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden'); + } +``` + +`tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`: + +```php + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + LibraryFixtures::given(array_merge( + [$this->page], + array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)) + )); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $diviWritable = true): LibraryInsertAbility + { + $access = LibraryFixtures::access($deniedRoles); + $failures = []; + + return new LibraryInsertAbility( + DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)), + $access, + static fn (): bool => $diviWritable + ); + } + + /** @return array */ + private function input(array $extra): array + { + return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra); + } + + private static function fixtureInstance(int $id, string $end): string + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''), $this->saved[0]); + self::assertSame('g0', $result['changed'][0]['id']); + self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings'])); + } + + public function testInsertsAGlobalModuleIntoAColumn(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]); + } + + public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1055])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString("\r", $this->saved[0]); + } + + public function testLayoutCopyInsertsEverySection(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy'])); + + self::assertCount(3, $result['changed']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void + { + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found'); + self::assertSame([], $this->saved); + } + + public function testNeedsAddLibraryAndAWritableDiviGroup(): void + { + self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported'); + self::assertTrue($this->ability()->isDirect()); + } + + public function testEditorCannotInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability()->execute($this->input(['library_id' => 1080])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } +} +``` + +In `DiviIntegrationTest::testProvidesTheLibraryGroup` die Liste auf `['lit/library-list', 'lit/library-save', 'lit/library-insert']` erweitern. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: FAIL (`canInsert`, `LibraryInsertAbility` fehlen; ToolNamingTest bleibt grün, bis das Tool existiert) + +- [ ] **Step 3: Implementieren** + +`LibraryAccess::canInsert()`: + +```php + /** Inserting from the library: canList() and the role option add_library (E3). */ + public function canInsert(): ?WP_Error + { + return $this->canList() ?? $this->roles->refusal('add_library'); + } +``` + +`src/Integrations/Divi/Library/LibraryInsertAbility.php`: + +```php + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'], + 'mode' => ['type' => 'string', 'enum' => ['copy', 'global']], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'library_id'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + protected function precheck(array $input): ?WP_Error + { + $refusal = $this->access->canInsert(); + if ($refusal !== null) { + return $refusal; + } + + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $item = $this->item($input['library_id'] ?? null); + if ($item instanceof WP_Error) { + return $item; + } + + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + $mode = is_string($input['mode'] ?? null) ? $input['mode'] : ($global ? 'global' : 'copy'); + if ($mode === 'global' && ! $global) { + return Errors::invalidInput( + 'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.', + ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden'] + ); + } + + $blocks = $this->blocks($item, $mode, $global); + if ($blocks instanceof WP_Error) { + return $blocks; + } + + $guard = $composition->storedGuard(); + foreach ($blocks as $block) { + $problems = $guard->problems($block); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; ' + . 'nichts wurde gespeichert.', + ['library_id' => implode('; ', $problems)] + ); + } + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, $blocks); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.', + ['library_id' => $violation] + ); + } + + $change = new DiviChange(sprintf( + 'Bibliothekseintrag „%s“ %s eingefügt', + (string) $item->post_title, + $mode === 'global' ? 'als globale Instanz' : 'als Kopie' + )); + foreach ($blocks as $block) { + $change->record($block, 'inserted'); + } + if ($mode === 'global') { + $change->warning(sprintf( + 'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit ' + . 'diesem Modul (lit-divi-outline mit post_id %d).', + $id, + $id + )); + } + + return $change; + } + + /** The active library entry $libraryId the user may read, or the error. */ + private function item(mixed $libraryId): WP_Post|WP_Error + { + $id = PostGuard::id($libraryId); + $item = $id === null ? null : get_post($id); + if ( + ! $item instanceof WP_Post + || $item->post_type !== DiviLibraryStore::POST_TYPE + || $item->post_status !== 'publish' + ) { + return Errors::notFound( + sprintf('Bibliothekseintrag %s gibt es nicht (oder er ist nicht aktiv).', (string) ($id ?? '?')), + 'Einträge mit lit/library-list auflisten.' + ); + } + + return current_user_can('read_post', (int) $item->ID) + ? $item + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu lesen.', (int) $item->ID)); + } + + /** @return list|WP_Error The blocks to insert. */ + private function blocks(WP_Post $item, string $mode, bool $global): array|WP_Error + { + $id = (int) $item->ID; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + return Errors::unsupported( + sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id), + 'Den Eintrag im Divi-Builder öffnen und speichern.' + ); + } + + if ($roots === []) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id), + ['library_id' => 'leer'] + ); + } + + if ($mode === 'global') { + if (count($roots) !== 1) { + return Errors::invalidInput( + 'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde ' + . 'gespeichert.', + ['mode' => 'mode "copy" verwenden'] + ); + } + + return [GlobalInstance::of($roots[0], $id)]; + } + + return array_map( + static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(), + $roots + ); + } +} +``` + +`LibraryIntegration` – Konstruktor um `private ?Closure $diviWritable = null` (letzter Parameter, Import `Closure`) erweitern und in `abilities()` ergänzen: + +```php + new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable), +``` + +`DiviIntegration::library(?Closure $diviWritable = null)` reicht `$diviWritable` als letzten Parameter an `LibraryIntegration` weiter. `Plugin::boot()`: `$divi->library($diviWritable)`. + +`docs/guides/overview.md` – „Direkt sichtbar:“ um `lit-library-insert` ergänzen (nach `lit-divi-delete`, Reihenfolge wie bisher, der Absatz endet mit einer Leerzeile – der ToolNamingTest liest bis dahin): + +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, +`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, +`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`. +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit-library-insert as copy or global instance in the builder's format (E5)`. + +--- + +### Task 14: `lit/divi-node-copy` – Knoten zwischen Seiten kopieren (E6) + +Kopiert einen gespeicherten Knoten byte-gleich an eine Position eines anderen Posts (Seite oder Bibliothekseintrag). Quelle: Leserechte wie `lit-divi-outline` (auch Bibliothekseinträge) und die `source_tree_rev` (Pfad-IDs gelten nur für ihren Stand); Ziel: wie `lit-divi-insert` (`tree_rev`, StructureRules, alles oder nichts, bei Bibliothekszielen `GlobalEdits`). Globale Instanzen bleiben verknüpft. Die Kopie läuft durch `StoredNodeGuard` des Ziels. Gruppe „Divi“, indirekt. + +**Files:** +- Create: `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriteAbility` (`precheck`, `change`, `commit`), `DiviPageLoader::load()`, `DiviComposition::storedGuard/editor`, `GlobalEdits`, `LibraryFixtures`. +- Produces: `final class DiviNodeCopyAbility extends DiviWriteAbility { __construct(DiviWriter $writer, DiviPageLoader $loader) }` – `lit/divi-node-copy`, indirekt, Eingabe `post_id`, `tree_rev`, `source_post_id`, `source_id`, `source_tree_rev`, Position. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`: + +```php + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + $this->source = LibraryFixtures::page( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + ['ID' => 8, 'post_title' => 'Quelle'] + ); + LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]); + } + + private function ability(): DiviNodeCopyAbility + { + $access = LibraryFixtures::access(); + $loader = LibraryFixtures::loader($access); + $failures = []; + $globals = new GlobalEdits($access, Mockery::mock(GlobalUsages::class)); + + return new DiviNodeCopyAbility( + DiviWriterFixture::writer($this->saved, $failures, false, $loader, $globals), + $loader + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge([ + 'post_id' => 7, + 'tree_rev' => Rev::of((string) $this->target->post_content), + 'source_post_id' => 8, + 'source_tree_rev' => Rev::of((string) $this->source->post_content), + 'source_id' => 's1', + 'after' => 's0', + ], $extra); + } + + public function testCopiesANodeByteForByteBetweenPages(): void + { + $bytes = Serializer::block( + NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find('s1') + ); + + $result = $this->ability()->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertCount(1, $this->saved, 'only the target is written'); + self::assertStringContainsString($bytes, $this->saved[0]); + self::assertSame('s1', $result['changed'][0]['id']); + self::assertFalse($this->ability()->isDirect()); + } + + public function testGlobalInstancesStayLinked(): void + { + $result = $this->ability()->execute($this->input(['source_id' => 'g0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability()->execute($this->input(['source_id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testTheSourceNeedsItsOwnRights(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8) + ); + + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + } + + public function testCopiesIntoALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1055, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.m0', + 'after' => null, + 'parent' => 's0.r0.c0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]); + } +} +``` + +In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-node-copy'` ergänzen. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest'` +Expected: FAIL (`DiviNodeCopyAbility` fehlt) + +- [ ] **Step 3: Implementieren** + +`src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`: + +```php + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'source_post_id' => ['type' => 'integer', 'minimum' => 1], + 'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'], + 'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** The source needs its own read rights – checked in permission() too. */ + protected function precheck(array $input): ?WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + + return $source instanceof WP_Error ? $source : null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) { + return Errors::revMismatch( + 'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde gespeichert.', + 'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.' + ); + } + + $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : ''; + $node = $source->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $source->post); + } + + $copy = $node->copy(); + $problems = $composition->storedGuard()->problems($copy); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'gespeichert.', + ['source_id' => implode('; ', $problems)] + ); + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, [$copy]); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.', + ['source_id' => $violation] + ); + } + + return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID))) + ->record($copy, 'inserted'); + } +} +``` + +`DiviIntegration::abilities()` – nach `DiviReplaceTextAbility` ergänzen: `new DiviNodeCopyAbility($writer, $loader),`. + +`docs/guides/overview.md`: „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`)“ → „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Pages/DiviNodeCopyAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-node-copy between pages and library entries`. + +--- + +### Task 15: `lit/divi-dynamic-sources` (E12, Antwort F4) + +E14 ordnet das Tool 3a zu. Es liefert Divis Dynamic-Content-Optionen (`DynamicContentOptions::get_options` für einen Beispiel-Eintrag des Typs, sonst 0; über `DynamicContentMap`, ohne `loop_*`, S36) mit dem passenden Platzhalter (`{{post:…}}`, `{{link:}}`) bzw. dem Namen für die Vollform, und die Felder des Typs aus `ContentSchema` (Pods, registriertes und gefundenes Meta). Antwort F4 (verbindlich): nur textartige Felder als Quelle anbieten, andere mit Grund auslassen (Beziehung/Datei zeigt Divi nur als IDs, F26; Pods mit Tabellen-Speicherung liest Divi nicht; geschützte Schlüssel mit `_` nie, S12). Die Feldliste nur mit der Divi-Rolle `read_dynamic_content_custom_fields` (E3, F25). Gruppe „Divi“, indirekt, nur lesend. + +**Files:** +- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`options()`) +- Create: `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `docs/guides/overview.md` (indirekte Tools) +- Test: `tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DynamicContentMap::fromOptions()`, `DynamicContentRules::DEFAULT_DYNAMIC_CONTENT/isMetaKey()`, `Placeholders::POST`, `ContentSchema::fields()`, `FieldDefinition`, `PostGuard::postType()`, `JsonMap::of()`, `DiviRoles`. +- Produces: `DynamicContentMap::options(int $postId): ?array}>`; `final class DiviDynamicSourcesAbility implements Ability { __construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null) }` – `lit/divi-dynamic-sources`. + +- [ ] **Step 1: Failing tests schreiben** + +In `DynamicContentMapTest`: + +```php + public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']); + self::assertSame('post_meta_key', $options['post_meta_key']['label']); + self::assertSame('Standard', $options['post_meta_key']['group']); + self::assertArrayNotHasKey('loop_post_title', $options); + self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831)); + } +``` + +`tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`: + +```php +justReturn( + (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] + ); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('fields')->with('page')->andReturn([ + 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), + 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), + 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), + 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), + '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), + 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), + ]); + + return $schema; + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], ?string $storage = 'meta', ?array $registry = null): DiviDynamicSourcesAbility + { + $registry ??= DiviFixtures::dynamicContentRegistry(); + + return new DiviDynamicSourcesAbility( + $this->schema(), + new DynamicContentMap(static fn (int $id): array => $registry), + LibraryFixtures::roles($deniedRoles), + static fn (string $postType): int => 831, + static fn (string $postType): ?string => $storage + ); + } + + public function testListsDiviSourcesWithTheirPlaceholders(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + $sources = array_column($result['sources'], null, 'name'); + self::assertSame(831, $result['sample_post_id']); + self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['site_title']['placeholder']); + self::assertArrayNotHasKey('loop_post_title', $sources); + self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); + self::assertFalse($this->ability()->isDirect()); + self::assertTrue($this->ability()->annotations()['readonly']); + } + + public function testOffersTextLikeFieldsAndNamesTheOthers(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); + self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); + $omitted = array_column($result['omitted'], 'reason', 'key'); + self::assertSame(['foto', 'projekte', '_intern'], array_keys($omitted)); + self::assertStringContainsString('nur IDs', $omitted['foto']); + self::assertStringContainsString('„_“', $omitted['_intern']); + } + + public function testPodsTableStorageIsNotReadableByDivi(): void + { + $result = $this->ability([], 'table')->execute(['post_type' => 'page']); + + self::assertSame(['telefon'], array_column($result['fields'], 'key')); + self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); + } + + public function testFieldListNeedsTheDiviRole(): void + { + $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); + + self::assertSame([], $result['fields']); + self::assertSame([], $result['omitted']); + self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); + } + + public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void + { + $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); + + self::assertContains('post_title', array_column($result['sources'], 'name')); + self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); + } + + public function testUnknownPostTypeIsNotFound(): void + { + Functions\when('get_post_type_object')->justReturn(null); + + self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); + } +} +``` + +In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-dynamic-sources'` ergänzen. + +- [ ] **Step 2: Tests laufen lassen – schlagen fehl** + +Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest'` +Expected: FAIL + +- [ ] **Step 3: Implementieren** + +`DynamicContentMap` – `forPost()` auf eine gemeinsame Rohabfrage umstellen und `options()` ergänzen: + +```php + /** @return array>|null Null = use DynamicContentRules' default map. */ + public function forPost(int $postId): ?array + { + $raw = $this->raw($postId); + + return $raw === null ? null : self::fromOptions($raw); + } + + /** + * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same + * names and types as forPost(), loop_* left out). Null when Divi's registry is unavailable. + * + * @return array}>|null + */ + public function options(int $postId): ?array + { + $raw = $this->raw($postId); + if ($raw === null) { + return null; + } + + $options = []; + foreach (self::fromOptions($raw) as $name => $settings) { + $option = is_array($raw[$name] ?? null) ? $raw[$name] : []; + $options[$name] = [ + 'label' => is_string($option['label'] ?? null) ? $option['label'] : $name, + 'group' => is_string($option['group'] ?? null) ? $option['group'] : '', + 'settings' => $settings, + ]; + } + + return $options; + } + + /** @return array|null */ + private function raw(int $postId): ?array + { + try { + $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); + } catch (Throwable $e) { + Log::failure(sprintf('divi dynamic content options of post %d', $postId), $e->getMessage()); + + return null; + } + + return is_array($options) && $options !== [] ? $options : null; + } +``` + +`src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`: + +```php +}} (answer + * F4: text-like fields only; relation/file fields show IDs in Divi (F26), Pods table storage is not + * post meta, protected keys never (S12) – each left out with its reason). The field list needs the + * Divi role option read_dynamic_content_custom_fields (E3, F25). + */ +final class DiviDynamicSourcesAbility implements Ability +{ + /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ + private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice', 'unknown']; + + private const ID_TYPES = ['relation', 'file']; + + /** @var Closure(string): int */ + private Closure $samplePost; + + /** @var Closure(string): ?string */ + private Closure $podStorage; + + /** + * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). + * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null without a pod. + */ + public function __construct( + private ContentSchema $schema, + private DynamicContentMap $map, + private DiviRoles $roles, + ?Closure $samplePost = null, + ?Closure $podStorage = null + ) { + $this->samplePost = $samplePost ?? static function (string $postType): int { + $ids = get_posts([ + 'post_type' => $postType, + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => 1, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + return is_array($ids) && isset($ids[0]) ? (int) $ids[0] : 0; + }; + $this->podStorage = $podStorage ?? static function (string $postType): ?string { + if (! function_exists('pods_api')) { + return null; + } + try { + $pod = pods_api()->load_pod(['name' => $postType]); + } catch (Throwable) { + return null; + } + + return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; + }; + } + + public function name(): string + { + return 'lit/divi-dynamic-sources'; + } + + public function label(): string + { + return 'Dynamic-Content-Quellen'; + } + + public function description(): string + { + return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' + . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' + . 'Datei- und Tabellenfelder stehen mit Grund unter omitted.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_type' => ['type' => 'string'], + 'sample_post_id' => ['type' => 'integer'], + 'sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], + ], + ], + 'fields' => ['type' => 'array', 'items' => ['type' => 'object']], + 'omitted' => ['type' => 'array', 'items' => ['type' => 'object']], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $sample = ($this->samplePost)($postType); + $notes = [ + 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' + . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', + ]; + $options = $this->map->options($sample); + if ($options === null) { + $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; + $options = []; + foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { + $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; + } + } + + $sources = []; + foreach ($options as $name => $option) { + $sources[] = [ + 'name' => (string) $name, + 'label' => $option['label'], + 'group' => $option['group'], + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($option['settings']), + ]; + } + + $fields = []; + $omitted = []; + if ($this->roles->allows('read_dynamic_content_custom_fields')) { + [$fields, $omitted] = $this->fields($postType); + } else { + $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' + . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; + } + + return [ + 'post_type' => $postType, + 'sample_post_id' => $sample, + 'sources' => $sources, + 'fields' => $fields, + 'omitted' => $omitted, + 'notes' => $notes, + ]; + } + + private static function placeholder(string $name): ?string + { + $post = array_search($name, Placeholders::POST, true); + if ($post !== false) { + return '{{post:' . $post . '}}'; + } + + return str_starts_with($name, 'post_link_url_') ? '{{link:}}' : null; + } + + /** + * @return array{0: list>, 1: list>} Offered and omitted fields. + */ + private function fields(string $postType): array + { + $storage = ($this->podStorage)($postType); + $fields = []; + $omitted = []; + foreach ($this->schema->fields($postType) as $field) { + if ($field->status === FieldDefinition::HIDDEN) { + continue; + } + + $entry = ['key' => $field->key, 'label' => $field->label]; + $reason = match (true) { + ! DynamicContentRules::isMetaKey($field->key) => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', + $field->source === 'pods' && $storage === 'table' => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', + in_array($field->type, self::ID_TYPES, true) => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', + ! in_array($field->type, self::TEXT_TYPES, true) => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), + default => null, + }; + if ($reason !== null) { + $omitted[] = $entry + ['reason' => $reason]; + continue; + } + + $fields[] = $entry + [ + 'type' => $field->type, + 'source' => $field->source, + 'placeholder' => '{{field:' . $field->key . '}}', + ]; + } + + return [$fields, $omitted]; + } +} +``` + +`DiviIntegration::abilities()` – nach `DiviNodeCopyAbility` (Import `DynamicContentMap`, `DiviDynamicSourcesAbility`): + +```php + new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), +``` + +`docs/guides/overview.md`: „…, `lit/divi-node-copy`)“ → „…, `lit/divi-node-copy`, `lit/divi-dynamic-sources`)“. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest|ToolNamingTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi +``` +Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-dynamic-sources with text-like fields only (E12, F4)`. + +--- + +### Task 16: Guides, Server-Instructions, Readme, Version 0.4.0, Spec- und Entscheidungs-Nachträge + +Dokumentation zu allem aus 3a, deutsch mit Umlauten, neutral formuliert (keine Chatbot-Marke; der Readme-Satz „z. B. Claude oder ein anderer MCP-fähiger Client“ wird dabei neutralisiert). Neues Guide-Thema `divi-library` mit dem Ablauf „Globale Module vorschlagen“ (E14; MCP-Prompts gibt es im Plugin noch nicht, siehe offene Punkte). Server-Instructions bekommen eine Zeile zur Bibliothek, wenn die Gruppe aktiv ist. + +**Files:** +- Create: `docs/guides/divi-library.md` +- Modify: `docs/guides/overview.md`, `docs/guides/divi-placeholders.md`, `docs/guides/divi-structure.md`, `docs/guides/divi-design-system.md`, `docs/guides/workflows.md` +- Modify: `src/Guides/InstructionsBuilder.php` +- Modify: `readme.txt`, `wp-lit-mcp.php`, `src/Plugin.php` +- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`, `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` +- Test: `tests/Unit/Guides/InstructionsBuilderTest.php`, `tests/Unit/Guides/ToolNamingTest.php` (unverändert, muss grün bleiben) + +**Interfaces:** +- Consumes: alle Tools aus Task 2–15. +- Produces: Guide-Thema `divi-library`; `Plugin::VERSION = '0.4.0'`; Instructions-Zeile „Divi-Bibliothek: …“. + +- [ ] **Step 1: Failing test schreiben** + +In `InstructionsBuilderTest`: + +```php + public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void + { + $library = Mockery::mock(Integration::class); + $library->allows('id')->andReturn('divi-library'); + $library->allows('isAvailable')->andReturn(true); + $library->allows('abilities')->andReturn([$this->ability('lit/library-insert', true)]); + $core = $this->coreWith($this->ability('lit/guide', true)); + + $with = (new InstructionsBuilder(new IntegrationRegistry($core, $library)))->build(); + $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('Divi-Bibliothek: globale Module', $with); + self::assertStringContainsString('confirm: true', $with); + self::assertStringNotContainsString('Divi-Bibliothek:', $without); + self::assertLessThan(2000, strlen($with)); + } +``` + +- [ ] **Step 2: Test laufen lassen – schlägt fehl** + +Run: `vendor/bin/phpunit --filter InstructionsBuilderTest` +Expected: FAIL + +- [ ] **Step 3: Guide `divi-library` anlegen** + +`docs/guides/divi-library.md`: + +```markdown +# Divi-Bibliothek und globale Module + +Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung. + +- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig. +- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des + Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf + veröffentlichten. + +## Werkzeuge + +- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` + (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search`, `category`, weiterblättern + mit `cursor`. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen + Einträgen die Seiten, die ihn zeigen (`affected`, `affected_count`). +- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen + Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. + Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` + der Zielseite ist Pflicht. +- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`title`, + `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der Knoten auf der Seite bleibt eine + eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz ersetzen (siehe Ablauf unten). +- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren + (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle; Ziel mit `post_id`, + `tree_rev`, Position). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. +- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, + `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete` und `lit/divi-duplicate-node` + mit der ID des Eintrags als `post_id`. + +## Globale Module erkennen und ändern + +1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058] + (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite + lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. +2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. +3. Ohne `confirm` ist das ein Probelauf: Antwort `confirm_required` mit `affected` (betroffene Seiten, Titel + nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; gespeichert wird nichts. +4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit + `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). +5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine + Sicherungskopie (`backup_id`). Divis CSS-Cache wird für alle Seiten geleert. + +Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses +Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; ein globaler +Eintrag enthält keine globalen Module. + +`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt +sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen. + +## Rechte + +- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen + Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge + sind immer aktiv). +- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“, + „Globale Bibliothekselemente bearbeiten“. Fehlt eines, antwortet das Werkzeug mit `forbidden` und diesem + Hinweis. +- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf + Bibliothekseinträge, auch über die Divi-Werkzeuge; `lit-library-insert` ist außerdem gesperrt, wenn + `lit-divi-insert` gesperrt ist. +- Ohne das Recht `unfiltered_html` lassen sich Knoten mit eigenem CSS, eigenen HTML-Attributen, dem + Code-Modul oder dynamischem Inhalt mit HTML davor/danach nicht in einen anderen Eintrag übertragen + (Kopieren, Einfügen, Speichern) – „nur im Divi-Builder“. + +## Ablauf: globale Module vorschlagen + +1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder + Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action). +2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt + danach auf allen Seiten. +3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern. +4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt + einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen. +5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen. + +Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt. +``` + +- [ ] **Step 4: Übrige Guides anpassen** + +`docs/guides/overview.md`: +- In „Weitere Themen:“ `divi-library` ergänzen: + +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`. +``` + +- Direkt darunter einen eigenen Absatz (nach einer Leerzeile, damit der Themen-Test ihn nicht mitliest): + +```markdown +Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf. +``` + +- Nach „Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.“ ergänzen: „Sie arbeiten + auch auf Einträgen der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`.“ + +`docs/guides/divi-placeholders.md` – Tabellenzeile nach `{{field:}}` einfügen: + +```markdown +| `{{link:}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden | +``` + +Den Absatz „In Link-Feldern (`url`, `button_url` …) …“ ersetzen durch: + +```markdown +In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:}}` verwenden statt fester +Adressen wie `/?page_id=…`. Der Eintrag muss existieren, lesbar sein und darf nicht im Papierkorb liegen; +`{{link:…}}` außerhalb von Link-Feldern lehnt das Werkzeug ab. Sonst nimmt ein Link-Feld jeden dynamischen +Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine +Link-Variable). + +Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources` +(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als +`{{field:…}}` eignen (nur textartige; Beziehungs- und Dateifelder zeigt Divi nur als IDs). +``` + +`docs/guides/divi-structure.md` – den ersten Punkt unter „Was nicht bearbeitet wird“ ersetzen durch: + +```markdown +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, + duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global ]`: ihren Inhalt im + Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe + `lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in + `skipped_nodes`. +``` + +`docs/guides/divi-design-system.md` – vor „Regeln:“ einfügen: + +```markdown +Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile, +`module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens 200 Presets je Antwort. +``` + +`docs/guides/workflows.md` – nach „Divi-Seite bauen“ einfügen: + +```markdown +## Bibliothek und globale Module + +Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert` +einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`. +Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen: +`lit-guide` topic `divi-library`. +``` + +Im Abschnitt „Text auf vielen Seiten ersetzen“ den letzten Satz ersetzen durch: „Globale Module werden nicht +geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic +`divi-library`).“ + +- [ ] **Step 5: Server-Instructions** + +`InstructionsBuilder::build()` – nach dem `$divi`-Block: + +```php + $library = str_contains(', ' . $active . ',', ', divi-library,') + ? [ + '- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global ]) im Bibliothekseintrag ändern; ' + . 'das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true (lit-guide topic ' + . '"divi-library").', + ] + : []; +``` + +und im `implode` `array_merge([...], $divi, $library)`. + +- [ ] **Step 6: Readme und Version** + +`wp-lit-mcp.php`: `* Version: 0.4.0`. `src/Plugin.php`: `public const VERSION = '0.4.0';`. `readme.txt`: `Stable tag: 0.4.0`. + +`readme.txt` – im ersten Satz der Beschreibung „(z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth)“ durch „(ein MCP-fähiger Client mit OAuth)“ ersetzen und die Aufzählung um „Divi-Seiten und die Divi-Bibliothek (auch globale Module)“ ergänzen. + +Unter „Auswirkungen auf die gesamte Website“ anfügen: + +```text +* Divi-Bibliothek: LIT MCP legt Bibliothekseinträge selbst an (Post-Typ et_pb_layout, Status publish, mit + Divis Begriffen scope, layout_type, module_width und dessen Meta-Feldern) und benutzt keine Builder-Routen. + Eine Änderung an einem globalen Bibliothekseintrag wirkt sofort auf allen Seiten, die ihn zeigen; LIT MCP + verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei + abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten + und den Theme-Builder-Cache. +* Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek + speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab. +* Interne Links über {{link:}} nutzen Divis eigenen dynamischen Inhalt „ Link“: die Adresse wird beim + Anzeigen ermittelt. +``` + +Unter „Rückgängig machen“ nach dem Punkt „Divi-Seiten“ einfügen: + +```text +* Bibliothekseinträge: jede Änderung legt vorher eine Revision an (revision_id), bei abgeschalteten Revisionen + eine Sicherungskopie im Papierkorb (backup_id); zurück über WordPress (Eintrag der Divi-Bibliothek + bearbeiten → Revisionen). Neu angelegte Einträge (lit/library-save) in der Divi-Bibliothek löschen. +``` + +Unter „Frequently Asked Questions“ anfügen: + +```text += Was passiert, wenn der Assistent ein globales Modul ändert? = + +Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst nach einem Probelauf, der die +betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des +Bibliothekseintrags. +``` + +Changelog oben: + +```text += 0.4.0 = +* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag + speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt). +* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf + mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung. +* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy). +* Interne Links mit {{link:}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder. +* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und + lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module + hin; lit-site-status prüft die genutzten Divi-Funktionen. +* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek. +``` + +- [ ] **Step 7: Spec und Entscheidungen nachtragen** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`: + +- §4.3, zweiter Punkt, anfügen: „**Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“.“ +- §5.2, letzter Punkt: „(Bearbeitung ab Etappe 3)“ → „(bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2)“. +- §5.4, Platzhalter-Tabelle nach `{{field:}}`: „| `{{link:}}` | interner Link: Dynamic Content `post_link_url_` mit `post_id` (nur Link-Felder; Etappe 3a, E13a) |“ +- §5.7: Zeile `lit/divi-dynamic-sources` → „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeile `lit/library-list`, `lit/library-get` → „| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9) |“; Zeile `lit/library-insert` → „… als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt“; Zeile `lit/divi-node-copy` → „Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, S7/S26-Prüfung E6)“. +- §9.2, Themenliste: „`divi-library`“ und „`media`“ ergänzen sowie „Thema `all` liefert alle Themen (Etappe 3a)“. +- §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)“ + +In `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` am Ende anfügen: + +```markdown +## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md) + +- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` = + vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`, + ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen + `page--globale-instanzen.html`. +- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload); + `LibraryContent::load()` vereinheitlicht das vor jeder Übernahme. +- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`); + entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10). +- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch `$variable`-Verweise + (Ablehnung, wenn `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund: + R2-Befund 1 (before/after nach dem Autor des Ziel-Posts). +- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`). +- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe + „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf + Bibliothekseinträge (`ExposurePolicy::groupWritable`). +- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte + Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die + betroffenen Seiten. +- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5). +- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status`. +- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt. +``` + +- [ ] **Step 8: Tests und Lint** + +Run: `vendor/bin/phpunit --filter 'InstructionsBuilderTest|ToolNamingTest|GuideRepositoryTest|GuideAbilityTest'` +Expected: PASS + +Run: `composer test && composer lint` +Expected: OK + +Run: `grep -rn "Claude\|ChatGPT\|claude.ai" docs/guides readme.txt src/Admin src/Guides` +Expected: keine Treffer + +- [ ] **Step 9: Commit** + +```bash +git add docs/guides src/Guides/InstructionsBuilder.php readme.txt wp-lit-mcp.php src/Plugin.php docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md tests/Unit/Guides/InstructionsBuilderTest.php +``` +Commit wie in den Global Constraints mit der Nachricht `docs: guides, readme and spec for the Divi Library (v0.4.0)`. + +--- + +### Task 17: Ende-zu-Ende-Test `divi-library-smoke.sh` und Abnahme-Notiz + +Prüft 3a über den echten OAuth-Endpunkt in DDEV (Divi 5.2.1): Exposition, Speichern normal/global mit Divis Termen, Einfügen als Instanz und Kopie, globaler Probelauf/`confirm`, Knoten kopieren, `{{link:ID}}`, die Übernahmen, ein Redakteur ohne `unfiltered_html` und die Gruppe „nur lesen“. Räumt alles wieder auf. Danach die manuelle Abnahme-Notiz für die Punkte, die den Visual Builder brauchen. + +**Files:** +- Create: `tests/Integration/divi-library-smoke.sh` +- Create: `docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md` + +**Interfaces:** +- Consumes: `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `rpc`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`), Fixture `tests/Fixtures/divi/converted-landing.html`. +- Produces: Skript, das mit `ALL CHECKS PASSED` endet. + +- [ ] **Step 1: Skript schreiben** + +`tests/Integration/divi-library-smoke.sh`: + +```bash +#!/usr/bin/env bash +# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy) through the OAuth-protected +# endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh +# Creates draft pages and library entries (titles "LIT Smoke …"), a temporary role/user without +# unfiltered_html, and removes everything again (posts with their revisions, backups, change-log rows, +# role, user, app passwords, options). +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +EDITOR_LOGIN=lit-lib-editor +FIXTURES=$PLUGIN/tests/Fixtures/divi +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') +ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__') +CREATED_POSTS=() + +restore_exposure() { + if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then + wp option delete lit_mcp_exposure >/dev/null 2>&1 || true + else + wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true + fi +} + +cleanup() { + # Library entries and backups the tools created ("LIT Smoke …", also from a run that stopped early). + for id in $(wp post list --post_type=et_pb_layout --post_status=publish,draft,trash --s="LIT Smoke" --field=ID 2>/dev/null); do + CREATED_POSTS+=("$id") + done + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" + cleanup_app_passwords_for "$EDITOR_LOGIN" + LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval ' +global $wpdb; +$table = $wpdb->prefix . "lit_mcp_log"; +$u = get_user_by("login", getenv("LIT_LOGIN")); +if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); } +foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) { + $wpdb->delete($table, ["object_id" => $id]); +}' >/dev/null 2>&1 || true + wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true + wp role delete lit_lib_editor >/dev/null 2>&1 || true + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true + restore_exposure +} +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +create_page() { # $1 = markup (not a file), $2 = title -> prints the ID + LIT_CONTENT="$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +content_of() { # $1 = post ID + wp post get "$1" --field=post_content +} + +outline() { # $1 = post ID -> the lit-divi-outline result + tool lit-divi-outline "{\"post_id\":$1}" +} + +# 0. Two pages from the converted landing page fixture +LANDING=$(cat "$FIXTURES/converted-landing.html") +PAGE_ID=$(create_page "$LANDING" "LIT Smoke Quelle"); CREATED_POSTS+=("$PAGE_ID") +TARGET_ID=$(create_page "$LANDING" "LIT Smoke Ziel"); CREATED_POSTS+=("$TARGET_ID") +connect "$ADMIN_LOGIN" + +# 1. Exposure: lit-library-insert direct, the other new tools indirect +LIST=$(rpc tools/list '{}') +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "lit-library-insert is not direct" +for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do + if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi +done +pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect" + +# 2. Save a section as normal library entry: Divi's terms, no Divi flags, unchanged node bytes +SOURCE=$(outline "$PAGE_ID") +REV=$(echo "$SOURCE" | jq -r '.tree_rev') +SECTION=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "section")][1].id') +SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg s "$SECTION" \ + '{post_id: $id, tree_rev: $rev, id: $s, title: "LIT Smoke Abschnitt"}')") +LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty') +[ -n "$LIB_ID" ] || fail "library-save: $SAVED" +CREATED_POSTS+=("$LIB_ID") +[ "$(wp post term list "$LIB_ID" layout_type --field=slug)" = "section" ] || fail "layout_type of $LIB_ID" +[ "$(wp post term list "$LIB_ID" scope --field=slug)" = "non_global" ] || fail "scope of $LIB_ID" +[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags" +[ "$(LIT_PAGE="$PAGE_ID" LIT_LIB="$LIB_ID" wp eval ' +$page = (string) get_post((int) getenv("LIT_PAGE"))->post_content; +$lib = (string) get_post((int) getenv("LIT_LIB"))->post_content; +$open = ""; $close = ""; +$inner = str_starts_with($lib, $open) && str_ends_with($lib, $close) ? substr($lib, strlen($open), -strlen($close)) : ""; +echo $inner !== "" && str_contains($page, $inner) ? "yes" : "no";')" = "yes" ] || fail "library entry does not hold the node bytes" +ability lit/library-list '{"search":"LIT Smoke"}' | jq -e --argjson id "$LIB_ID" '.items | map(.id) | index($id) != null' >/dev/null \ + || fail "library-list does not show $LIB_ID" +pass "lit/library-save: normal section with Divi's terms, node bytes in a divi/placeholder frame, listed" + +# 3. Save a module as global entry, insert it as instance into the target page +TEXT=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "text")][0].id') +GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg m "$TEXT" \ + '{post_id: $id, tree_rev: $rev, id: $m, title: "LIT Smoke Globales Modul", scope: "global"}')") +GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty') +[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED" +CREATED_POSTS+=("$GLOB_ID") +[ "$(wp post term list "$GLOB_ID" scope --field=slug)" = "global" ] || fail "scope of $GLOB_ID" +[ "$(wp post meta get "$GLOB_ID" _et_pb_excluded_global_options)" = "[]" ] || fail "excluded global options of $GLOB_ID" +TARGET=$(outline "$TARGET_ID") +COLUMN=$(echo "$TARGET" | jq -r '[.nodes[] | select(.type == "column")][0].id') +INSERTED=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$TARGET" | jq -r .tree_rev)" \ + --argjson lib "$GLOB_ID" --arg c "$COLUMN" '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: $c}')") +echo "$INSERTED" | jq -e '.ok == true' >/dev/null || fail "library-insert global: $INSERTED" +content_of "$TARGET_ID" | grep -q "' "LIT Smoke CSS") +CREATED_POSTS+=("$CSS_ID") +wp role create lit_lib_editor "LIT Smoke Bibliothek" --clone=editor >/dev/null +wp cap remove lit_lib_editor unfiltered_html >/dev/null +wp option update lit_mcp_allowed_roles '["administrator","lit_lib_editor"]' --format=json >/dev/null +wp user create "$EDITOR_LOGIN" lit-lib-editor@example.invalid --role=lit_lib_editor >/dev/null +connect "$EDITOR_LOGIN" +BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum) +CARRY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(outline "$TARGET_ID" | jq -r .tree_rev)" \ + --argjson src "$CSS_ID" --arg srev "$(outline "$CSS_ID" | jq -r .tree_rev)" \ + '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')") +[ "$(error_code "$CARRY")" = "validation_failed" ] || fail "editor carried custom CSS: $CARRY" +KEEP=$(ability lit/library-save "$(jq -cn --argjson id "$CSS_ID" --arg rev "$(outline "$CSS_ID" | jq -r .tree_rev)" \ + '{post_id: $id, tree_rev: $rev, id: "s0", title: "LIT Smoke CSS-Eintrag"}')") +[ "$(error_code "$KEEP")" = "validation_failed" ] || fail "editor saved custom CSS to the library: $KEEP" +[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "a refused copy wrote something" +pass "user without unfiltered_html: custom CSS neither copied nor saved to the library" + +# 10. Group "Divi-Bibliothek" read-only: no writes to library entries, lit-library-insert gone +wp option update lit_mcp_exposure '{"@readonly:divi-library":true}' --format=json >/dev/null +connect "$ADMIN_LOGIN" +if rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then + fail "lit-library-insert listed although the group is read-only" +fi +LREV=$(outline "$LIB_ID" | jq -r '.tree_rev') +[ "$LREV" != "null" ] || fail "library entry no longer readable" +BLOCKED=$(tool lit-divi-update "$(jq -cn --argjson id "$LIB_ID" --arg rev "$LREV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0", label: "LIT Smoke gesperrt"}]}')") +[ "$(error_code "$BLOCKED")" = "forbidden" ] || fail "write to a library entry with the group read-only: $BLOCKED" +restore_exposure +pass "group Divi-Bibliothek read-only: entries readable, not writable, lit-library-insert not listed" + +finish +``` + +- [ ] **Step 2: Skript ausführen** + +Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh` +Expected: alle Zeilen `ok - …`, am Ende `ALL CHECKS PASSED`. Danach prüfen, dass nichts übrig ist: + +Run: `cd /home/janniclas/Projekte/WordPress && ddev wp post list --post_type=et_pb_layout,page --post_status=publish,draft,trash --s="LIT Smoke" --format=count` +Expected: `0` + +Die übrigen Smokes laufen unverändert grün: + +Run: `cd /home/janniclas/Projekte/WordPress && for s in divi-write-smoke divi-read-smoke mcp-smoke wp-basis-smoke media-admin-smoke; do timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -1; done` +Expected: fünfmal `ALL CHECKS PASSED` + +- [ ] **Step 3: Abnahme-Notiz schreiben** + +`docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md`: + +```markdown +# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek) + +Automatisch geprüft sind bereits: + +- alle Unit-Tests (`composer test`) und Lint (`composer lint`); +- `divi-library-smoke.sh` (neu) und die fünf bisherigen Integrationsskripte (alle in `tests/Integration/`). + +`divi-library-smoke.sh` prüft über den OAuth-Endpunkt: `lit-library-insert` direkt, die übrigen neuen Tools +indirekt; Speichern als normaler und globaler Eintrag mit Divis Begriffen, ohne Divi-Kennzeichen und mit den +unveränderten Bytes des Knotens; Einfügen als Instanz im Format des Visual Builders und als Kopie; Probelauf, +`confirm` und Revision beim Ändern eines globalen Eintrags; Knoten kopieren (auch Ablehnung einer veralteten +Quelle); `{{link:ID}}`; die Übernahmen aus Etappe 2; einen Redakteur ohne `unfiltered_html`, der eigenes CSS +weder kopieren noch in die Bibliothek speichern kann; die Gruppe „Divi-Bibliothek“ auf „nur lesen“. + +Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen +als kurze Notiz zurückmelden; sie werden Folgeaufgaben. + +## 1. Vom Plugin angelegte Bibliothekseinträge + +1. Über den KI-Assistenten einen Abschnitt einer Entwurfsseite als normalen Eintrag und ein Modul als + globalen Eintrag speichern lassen. +2. Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit richtigem Typ (Abschnitt/Modul), der globale + als global. +3. Im Visual Builder einer Seite „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen sich + einfügen. + +## 2. Vom Plugin eingefügte globale Instanz + +1. Den globalen Eintrag mit `lit-library-insert` in eine Entwurfsseite einfügen lassen. +2. Seite im Visual Builder öffnen: das Modul ist als global markiert und zeigt den Inhalt der Bibliothek. +3. Das Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und auf einer + zweiten Seite mit derselben Instanz. +4. Vorschau der Seite: der Inhalt des globalen Moduls wird angezeigt. + +## 3. Globales Modul über den Assistenten ändern + +1. Den Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst die betroffenen Seiten nennen + und nachfragen. +2. Nach Zustimmung: Vorschau beider Seiten zeigt den neuen Text; unter Einstellungen → LIT MCP → Protokoll + steht der Eintrag mit Revision; die Revision ist in WordPress beim Bibliothekseintrag sichtbar. + +## 4. Kopie und Knoten kopieren + +1. Einen normalen Eintrag als Kopie einfügen und einen Abschnitt mit `lit/divi-node-copy` auf eine andere Seite + kopieren lassen. +2. Beide im Builder öffnen, ändern, speichern – nichts Ungewöhnliches (keine verknüpften Änderungen an anderen + Seiten). + +## 5. Interner Link + +1. Einen Button mit `{{link:}}` einfügen lassen. +2. Im Builder zeigt das Link-Feld dynamischen Inhalt „Seite Link“ mit der gewählten Seite. +3. Die Zielseite veröffentlichen: der Button verlinkt auf die schöne Adresse. + +## 6. Rechte und Einstellungen + +1. In Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente bearbeiten“ abschalten und als + Redakteur ein globales Modul ändern lassen: Ablehnung mit Hinweis auf den Rollen-Editor. +2. Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ ist sichtbar; „nur lesen“ entfernt + `lit-library-insert` aus der Werkzeugliste (nach neuer Verbindung). +3. `lit-site-status`: bei Divi keine Einträge unter `problems`. + +## Aufräumen + +Die Testeinträge in der Divi-Bibliothek und die Entwurfsseiten löschen (Titel wie bei der Abnahme vergeben). +Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an. +``` + +- [ ] **Step 4: Commit** + +```bash +chmod +x tests/Integration/divi-library-smoke.sh +git add tests/Integration/divi-library-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md +``` +Commit wie in den Global Constraints mit der Nachricht `test: end-to-end smoke and acceptance note for the Divi Library (3a)`. + +--- + +## Offene Punkte (nicht Teil dieses Plans) + +- `lit/revision-restore` erreicht Bibliothekseinträge nicht (`ContentSchema::EXCLUDED`); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b. +- `lit/divi-replace-text` durchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter als `skipped_nodes` gemeldet. +- MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`. +- Ob der Visual Builder Instanzen mit tief verschachtelten `localAttrs` anders sortiert als nur auf oberster Ebene, zeigt erst ein reicheres Builder-Fixture; die Abnahme (Abschnitt 2) prüft das Verhalten im Builder. +- Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz (`localAttrsMap`) bleibt Builder-Sache (E2). +- `lit/library-make-global`, `lit/library-find-similar`, Theme Builder: Etappe 3b. From 404f0f34b958834efd854eb1e015085d9a73af86 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:29:37 +0200 Subject: [PATCH 05/47] test: raise memory_limit for the unit suite (512M) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- composer.json | 2 +- phpunit.xml.dist | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e7c6ee8..875196c 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "psr-4": { "LIT\\MCP\\Tests\\": "tests/" } }, "scripts": { - "test": "@php vendor/bin/phpunit", + "test": "@php -d memory_limit=512M vendor/bin/phpunit", "lint": "@php vendor/bin/phpcs" }, "config": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6e7b6d4..e809d59 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,6 +3,10 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true"> + + + + tests/Unit From 788943e80978264c5324b3bb8121996a91058b6d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:32:59 +0200 Subject: [PATCH 06/47] feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/LinkTargets.php | 16 ++++ .../Divi/Compose/Placeholders.php | 46 ++++++++++-- src/Integrations/Divi/Compose/VariableRef.php | 10 ++- .../Divi/Pages/DiviComposition.php | 6 +- src/Integrations/Divi/Pages/WpLinkTargets.php | 30 ++++++++ tests/Support/ComposeFixture.php | 18 ++++- .../Divi/Compose/PlaceholdersTest.php | 39 +++++++++- .../Divi/Compose/ShortformWriterTest.php | 75 ++++++++++++++++++- .../Divi/Compose/VariableRefTest.php | 21 +++++- .../Divi/Pages/DiviCompositionTest.php | 27 ++++++- .../Divi/Pages/WpLinkTargetsTest.php | 37 +++++++++ 11 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 src/Integrations/Divi/Compose/LinkTargets.php create mode 100644 src/Integrations/Divi/Pages/WpLinkTargets.php create mode 100644 tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php diff --git a/src/Integrations/Divi/Compose/LinkTargets.php b/src/Integrations/Divi/Compose/LinkTargets.php new file mode 100644 index 0000000..4b6dea2 --- /dev/null +++ b/src/Integrations/Divi/Compose/LinkTargets.php @@ -0,0 +1,16 @@ +}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress. + */ +interface LinkTargets +{ + /** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */ + public function postTypeOf(int $id): ?string; +} diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 34dec92..c7439b4 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -12,10 +12,10 @@ defined('ABSPATH') || exit; /** * Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax: * {{color:primary||gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, - * {{post:title|…}}, {{field:}}. A placeholder must be the whole value (D9); unknown - * names fail with the list of existing ones, labels two entries share fail with their IDs - * (ruling S13a, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef - * wherever it is written. Pure PHP. + * {{post:title|…}}, {{field:}}, {{link:}} (internal link, URL fields only – E13a). + * A placeholder must be the whole value (D9); unknown names fail with the list of existing ones, + * labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor). + * The result is still checked by VariableRef wherever it is written. Pure PHP. */ final class Placeholders { @@ -37,9 +37,10 @@ final class Placeholders 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', ]; - private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; + private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/'; - public function __construct(private DesignSystem $design) + /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */ + public function __construct(private DesignSystem $design, private ?LinkTargets $links = null) { } @@ -54,7 +55,7 @@ final class Placeholders if (str_contains($value, '{{')) { throw new InvalidArgumentException( 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' - . 'post, field).' + . 'post, field, link).' ); } @@ -68,6 +69,7 @@ final class Placeholders 'var' => $this->variable($name), 'font' => $this->font($name), 'post' => $this->post($name), + 'link' => $this->link($name), default => $this->field($name), }; } @@ -152,6 +154,36 @@ final class Placeholders return VariableRef::serialize('content', $option, $settings); } + /** + * {{link:}} → Divi's own internal link post_link_url_ with settings.post_id (F27): + * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published. + * Whether Divi offers that option (public post types only) VariableRef checks against the registry; + * that the value goes into a URL field and the target is readable (S37) the choke point checks again. + * + * @throws InvalidArgumentException + */ + private function link(string $name): string + { + // FILTER_VALIDATE_INT: no id beyond PHP_INT_MAX that would saturate to another entry. + $id = preg_match('/^[1-9][0-9]{0,18}$/D', $name) === 1 ? filter_var($name, FILTER_VALIDATE_INT) : false; + if (! is_int($id)) { + throw new InvalidArgumentException(sprintf( + 'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).', + $name + )); + } + + $type = $this->links?->postTypeOf($id); + if ($type === null) { + throw new InvalidArgumentException(sprintf( + 'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.', + $name + )); + } + + return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]); + } + private function field(string $name): string { if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 1fe416c..2d1ee19 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -26,7 +26,8 @@ defined('ABSPATH') || exit; * settings hue/saturation/lightness/opacity, numbers only. * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_ * (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content - * setting. "enable_html" is always rejected here. + * setting. "enable_html" is always rejected here. Internal links post_link_url_ ({{link:…}}, + * E13a) only in URL fields. * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException. */ final class VariableRef @@ -232,6 +233,13 @@ final class VariableRef */ private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array { + if (! $url && str_starts_with($name, 'post_link_url_')) { + throw new InvalidArgumentException(sprintf( + '"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.', + $name + )); + } + $fields = $this->content->fieldsFor($name); $clean = []; foreach ($settings as $key => $value) { diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index df4ea27..f67c2e3 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; +use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; @@ -45,7 +46,8 @@ class DiviComposition private DesignSystemReader $design, private ValueSanitizer $sanitizer = new WpValueSanitizer(), private MediaResolver $media = new WpMediaResolver(), - private DynamicContentMap $dynamicContent = new DynamicContentMap() + private DynamicContentMap $dynamicContent = new DynamicContentMap(), + private LinkTargets $links = new WpLinkTargets() ) { } @@ -83,7 +85,7 @@ class DiviComposition */ public function placeholders(): Placeholders { - return $this->placeholders ??= new Placeholders($this->design->read()); + return $this->placeholders ??= new Placeholders($this->design->read(), $this->links); } /** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */ diff --git a/src/Integrations/Divi/Pages/WpLinkTargets.php b/src/Integrations/Divi/Pages/WpLinkTargets.php new file mode 100644 index 0000000..3db9db8 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpLinkTargets.php @@ -0,0 +1,30 @@ +}} targets from WordPress: the entry exists, lives (no trash, auto-draft or + * revision) and the current user may read it (read_post, like select_post settings, S37). + * Missing and unreadable entries give the same null (no existence oracle). + */ +final class WpLinkTargets implements LinkTargets +{ + private const DEAD = ['trash', 'auto-draft', 'inherit']; + + public function postTypeOf(int $id): ?string + { + $post = get_post($id); + if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) { + return null; + } + + return current_user_can('read_post', $id) ? (string) $post->post_type : null; + } +} diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index aec6f11..a8abb15 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; +use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; @@ -128,9 +129,24 @@ final class ComposeFixture }; } + /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */ + public static function links(): LinkTargets + { + return new class implements LinkTargets { + public function postTypeOf(int $id): ?string + { + return match ($id) { + 839 => 'page', + 840 => 'post', + default => null, + }; + } + }; + } + public static function placeholders(): Placeholders { - return new Placeholders(DiviFixtures::designSystem()); + return new Placeholders(DiviFixtures::designSystem(), self::links()); } /** The strict $variable(…)$ checker for the sample design system and the default map. */ diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index c25688e..9f1eb8c 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -6,13 +6,21 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class PlaceholdersTest extends TestCase { - /** @return array */ + /** + * Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode + * refuses colour references by design). + * + * @return array + */ public static function placeholders(): array { return [ @@ -31,20 +39,30 @@ final class PlaceholdersTest extends TestCase '{{field:telefon_mobil}}', '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', ], + 'internal link' => [ + '{{link:839}}', + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + true, + ], 'plain text' => ['Hallo Welt', 'Hallo Welt'], ]; } /** @dataProvider placeholders */ - public function testResolvesToDiviVariables(string $input, string $expected): void + public function testResolvesToDiviVariables(string $input, string $expected, bool $url = false): void { $placeholders = ComposeFixture::placeholders(); $resolved = $placeholders->resolve($input); self::assertSame($expected, $resolved); if ($expected !== $input) { - // What placeholders produce passes the strict checker unchanged (ruling S1/S25). - self::assertSame($resolved, ComposeFixture::references()->canonical($resolved, ComposeFixture::sanitizer())); + // What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live + // registry map knows post_link_url_page, the URL mode allows it. + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), $url)); } } @@ -60,6 +78,11 @@ final class PlaceholdersTest extends TestCase 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], 'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'], 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + 'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'], + 'link to a missing post' => ['{{link:999}}', 'Eintrag 999'], + 'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'], + 'link with a leading zero' => ['{{link:0839}}', 'Ungültige Beitrags-ID'], + 'link beyond PHP_INT_MAX' => ['{{link:9999999999999999999}}', 'Ungültige Beitrags-ID'], ]; } @@ -137,4 +160,12 @@ final class PlaceholdersTest extends TestCase ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) ); } + + public function testLinksNeedLinkTargets(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Eintrag 839'); + + (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}'); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 4ca6441..158a868 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -5,6 +5,11 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -352,5 +357,73 @@ final class ShortformWriterTest extends TestCase self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']); } } -} + /** E13a: {{link:ID}} becomes Divi's post_link_url_ in URL fields – and only there. */ + public function testInternalLinkOnlyInUrlFields(): void + { + $writer = self::linkWriter(); + + $button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n'); + self::assertSame([], $button['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$', + $button['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + + $text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']); + + // A post type the registry offers no link option for (fixture: page only): unknown name. + $post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n'); + self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']); + } + + /** + * The full form goes through the same choke point: URL fields only, no before/after, and the + * target must be readable (select_post, ruling S37 / preflight §4) – no path around ValueCleaner. + */ + public function testInternalLinkFullFormIsCheckedLikeThePlaceholder(): void + { + $writer = self::linkWriter(); + $link = static fn (string $settings): string => + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":' . $settings . '}})$'; + + $valid = $writer->apply('button', [], ['url' => $link('{"post_id":839}')], 'n'); + self::assertSame([], $valid['errors']); + self::assertSame($link('{"post_id":839}'), $valid['attrs']['button']['innerContent']['desktop']['value']['linkUrl']); + + $unreadable = $writer->apply('button', [], ['url' => $link('{"post_id":"666"}')], 'n'); + self::assertStringContainsString('lesbaren Eintrags', $unreadable['errors']['n.url']); + + $slug = $writer->apply('button', [], ['url' => $link('{"post_id":"kontakt"}')], 'n'); + self::assertStringContainsString('lesbaren Eintrags', $slug['errors']['n.url']); + + $extra = $writer->apply('button', [], ['url' => $link('{"post_id":"839","before":"x"}')], 'n'); + self::assertArrayHasKey('n.url', $extra['errors']); + self::assertSame([], $extra['attrs']); + + $inText = $writer->apply('heading', [], ['text' => $link('{"post_id":"839"}')], 'n'); + self::assertStringContainsString('nur in Link- und URL-Feldern', $inText['errors']['n.text']); + + // A control-split placeholder is the placeholder (S35) and resolves the same way. + $split = $writer->apply('button', [], ['url' => '{' . chr(0) . '{link:839}}'], 'n'); + self::assertSame([], $split['errors']); + self::assertSame( + $link('{"post_id":"839"}'), + $split['attrs']['button']['innerContent']['desktop']['value']['linkUrl'] + ); + } + + /** ShortformWriter with the live registry map (knows post_link_url_page) and the test link targets. */ + private static function linkWriter(): ShortformWriter + { + $design = DiviFixtures::designSystem(); + $cleaner = new ValueCleaner( + DiviFixtures::catalog(), + new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())), + ComposeFixture::sanitizer() + ); + + return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 770d110..c2d86de 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -421,11 +421,11 @@ final class VariableRefTest extends TestCase ); self::assertSame( self::content('post_link_url_page', '{"post_id":"12"}'), - $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer) + $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true) ); self::assertSame( self::content('post_link_url_page', '{"post_id":12}'), - $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer) + $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true) ); $rejected = [ @@ -440,12 +440,25 @@ final class VariableRefTest extends TestCase ]; foreach ($rejected as $case => [$value, $fragment]) { try { - $ref->canonical($value, $sanitizer); + // URL mode: internal links (post_link_url_*) are allowed only there (E13a). + $ref->canonical($value, $sanitizer, true); self::fail('Accepted: ' . $case); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage(), $case); } } } -} + /** E13a: an internal link (post_link_url_) is a URL – refused in every other field. */ + public function testInternalLinksOnlyInUrlFields(): void + { + $ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]); + $link = self::content('post_link_url_page', '{"post_id":"12"}'); + + self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('nur in Link- und URL-Feldern'); + $ref->canonical($link, ComposeFixture::sanitizer()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index a565513..d4a656f 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -37,7 +37,14 @@ final class DiviCompositionTest extends TestCase return DiviFixtures::dynamicContentRegistry(); }); - return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry); + return new DiviComposition( + $modules, + $design, + ComposeFixture::sanitizer(), + ComposeFixture::media(), + $registry, + ComposeFixture::links() + ); } private static function siteTitle(): string @@ -74,6 +81,24 @@ final class DiviCompositionTest extends TestCase self::assertSame($composition->references(), $composition->references()); } + /** E13a: the composition hands its link targets to Placeholders; the result still passes ValueCleaner. */ + public function testInternalLinksUseTheLinkTargets(): void + { + $composer = $this->composition()->forPost(7)->composer(); + + $column = Block::create('divi/column'); + + $page = $composer->compose([['type' => 'button', 'text' => 'Kontakt', 'url' => '{{link:839}}']], $column, 'nodes'); + self::assertSame([], $page['errors']); + self::assertStringContainsString( + 'post_link_url_page', + json_encode($page['blocks'][0]->attrs(), JSON_THROW_ON_ERROR) + ); + + $missing = $composer->compose([['type' => 'button', 'url' => '{{link:999}}']], $column, 'nodes'); + self::assertStringContainsString('Eintrag 999', $missing['errors']['nodes[0].url']); + } + public function testWithoutAPostTheDefaultMapApplies(): void { $composition = $this->composition(); diff --git a/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php new file mode 100644 index 0000000..d62ef23 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php @@ -0,0 +1,37 @@ + new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']), + 841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']), + 842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']), + 843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']), + 844 => new WP_Post(['ID' => 844, 'post_type' => 'page', 'post_status' => 'auto-draft']), + ]; + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842 + ); + + $links = new WpLinkTargets(); + + self::assertSame('page', $links->postTypeOf(839)); + self::assertNull($links->postTypeOf(841), 'trashed'); + self::assertNull($links->postTypeOf(842), 'not readable'); + self::assertNull($links->postTypeOf(843), 'revision'); + self::assertNull($links->postTypeOf(844), 'auto-draft'); + self::assertNull($links->postTypeOf(900), 'missing'); + } +} From 858aa6a182097240322c3845dd535aea7598c999 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:39:14 +0200 Subject: [PATCH 07/47] feat: lit-guide topic all returns every guide in one call Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Core/GuideAbility.php | 55 ++++++++++++++++++- .../Integrations/Core/GuideAbilityTest.php | 28 ++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/Integrations/Core/GuideAbility.php b/src/Integrations/Core/GuideAbility.php index 8cd7800..b9aaa28 100644 --- a/src/Integrations/Core/GuideAbility.php +++ b/src/Integrations/Core/GuideAbility.php @@ -16,6 +16,12 @@ final class GuideAbility implements Ability { private const DEFAULT_TOPIC = 'overview'; + /** topic = all: every topic in one call (overview first). */ + public const ALL = 'all'; + + /** Upper bound of the combined text of topic = all (bytes). */ + public const ALL_LIMIT = 100000; + public function __construct(private GuideRepository $guides) { } @@ -32,7 +38,8 @@ final class GuideAbility implements Ability public function description(): string { - return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.'; + return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; ' + . 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).'; } public function inputSchema(): array @@ -42,7 +49,7 @@ final class GuideAbility implements Ability 'properties' => [ 'topic' => [ 'type' => 'string', - 'description' => 'Thema, z. B. "overview" oder "divi-shortform".', + 'description' => 'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.', ], ], 'additionalProperties' => false, @@ -83,6 +90,10 @@ final class GuideAbility implements Ability ? $input['topic'] : self::DEFAULT_TOPIC; $topics = $this->guides->topics(); + if ($topic === self::ALL) { + return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)]; + } + $content = $this->guides->get($topic); if ($content === null) { @@ -94,4 +105,44 @@ final class GuideAbility implements Ability return ['topic' => $topic, 'topics' => $topics, 'content' => $content]; } + + /** + * Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names + * the topics left out. + * + * @param list $topics + */ + private function all(array $topics): string + { + $ordered = in_array(self::DEFAULT_TOPIC, $topics, true) + ? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC]))) + : $topics; + $parts = []; + $size = 0; + $left = []; + foreach ($ordered as $topic) { + $content = $this->guides->get($topic); + if ($content === null) { + continue; + } + + $part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content)); + if ($size + strlen($part) > self::ALL_LIMIT) { + $left[] = $topic; + continue; + } + + $parts[] = $part; + $size += strlen($part) + 2; + } + + if ($left !== []) { + $parts[] = sprintf( + 'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.', + implode(', ', $left) + ); + } + + return implode("\n\n", $parts); + } } diff --git a/tests/Unit/Integrations/Core/GuideAbilityTest.php b/tests/Unit/Integrations/Core/GuideAbilityTest.php index a669e86..10d30dd 100644 --- a/tests/Unit/Integrations/Core/GuideAbilityTest.php +++ b/tests/Unit/Integrations/Core/GuideAbilityTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Core; +use Brain\Monkey\Filters; use LIT\MCP\Guides\GuideRepository; use LIT\MCP\Integrations\Core\GuideAbility; use LIT\MCP\Tests\Unit\TestCase; @@ -50,4 +51,31 @@ final class GuideAbilityTest extends TestCase self::assertTrue($this->ability->isDirect()); self::assertTrue($this->ability->annotations()['readonly']); } + + public function testAllTopicsInOneCallOverviewFirst(): void + { + $result = $this->ability->execute(['topic' => 'all']); + + self::assertSame('all', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + $overview = strpos($result['content'], 'Thema `overview`'); + $shortform = strpos($result['content'], 'Thema `divi-shortform`'); + self::assertIsInt($overview); + self::assertIsInt($shortform); + self::assertLessThan($shortform, $overview); + self::assertStringNotContainsString('Nicht enthalten', $result['content']); + } + + public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void + { + $big = str_repeat('x', GuideAbility::ALL_LIMIT); + // Brain Monkey owns apply_filters(): answer the guide filter through its hooks API. + Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]); + $ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + + $content = $ability->execute(['topic' => 'all'])['content']; + + self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content)); + self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content); + } } From a3a807051f53c5671d66c92fd3de3e3b74f58b6e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:43:08 +0200 Subject: [PATCH 08/47] feat: design-system filters and global module ids in node-get/outline (S19) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Divi/BlockTree/NodeSummary.php | 28 +++++++- src/Integrations/Divi/BlockTree/Outline.php | 17 ++++- .../Divi/Design/DiviDesignSystemAbility.php | 69 ++++++++++++++++--- .../Divi/Pages/DiviNodeGetAbility.php | 12 ++++ .../Divi/Pages/DiviOutlineAbility.php | 9 ++- .../Divi/BlockTree/OutlineTest.php | 21 +++++- .../Design/DiviDesignSystemAbilityTest.php | 35 ++++++++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 15 ++++ 8 files changed, 187 insertions(+), 19 deletions(-) diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index c5d57a8..24da786 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -25,7 +25,10 @@ final class NodeSummary public static function label(Block $block): string { - $label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel'); + $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) + ? $block->attrs()['localAttrs'] + : $block->attrs(); + $label = AttrMap::desktop($attrs, 'module.meta.adminLabel'); if (! is_string($label) || $label === '') { return ''; } @@ -55,6 +58,29 @@ final class NodeSummary return ''; } + /** + * Library entry of a global module: a divi/global-layout instance (its root type in blockName) + * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block. + * + * @return array{id: string, block_name: string}|null + */ + public static function globalRef(Block $block): ?array + { + if (! $block->isGlobal()) { + return null; + } + + $attrs = $block->attrs(); + $id = $attrs['globalModule'] ?? null; + if (! is_string($id) && ! is_int($id)) { + return null; + } + + $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name(); + + return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : '']; + } + /** @return list */ public static function presetIds(Block $block): array { diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index a364371..5d7920a 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -59,7 +59,8 @@ final class Outline /** * @return list, children: int, editable: bool, depth: int, truncated: bool}> + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string}> */ public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { @@ -114,6 +115,7 @@ final class Outline foreach ($rows as $row) { $line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type']; $line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : ''; + $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; @@ -127,15 +129,23 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int, truncated: bool} + * presets: list, children: int, editable: bool, depth: int, truncated: bool, + * global_id: ?string} */ private function row(Block $block, string $id, int $depth): array { $editable = $block->isDivi() && ! $block->isOpaque(); + $global = NodeSummary::globalRef($block); + $type = $block->isDivi() ? $block->shortName() : $block->name(); + if ($global !== null && $global['block_name'] !== '') { + $type = str_starts_with($global['block_name'], 'divi/') + ? substr($global['block_name'], 5) + : $global['block_name']; + } return [ 'id' => $id, - 'type' => $block->isDivi() ? $block->shortName() : $block->name(), + 'type' => $type, 'label' => NodeSummary::label($block), 'excerpt' => NodeSummary::excerpt($block), 'info' => $editable ? NodeSummary::info($block) : '', @@ -147,6 +157,7 @@ final class Outline 'editable' => $editable, 'depth' => $depth, 'truncated' => false, + 'global_id' => $global['id'] ?? null, ]; } } diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php index a85f7fe..1ecf8e8 100644 --- a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -18,6 +18,11 @@ defined('ABSPATH') || exit; */ final class DiviDesignSystemAbility implements Ability { + /** Presets per answer (ruling S19); narrow with "module". */ + public const PRESET_LIMIT = 200; + + private const PARTS = ['colors', 'variables', 'presets']; + public function __construct(private DesignSystemReader $reader) { } @@ -36,12 +41,28 @@ final class DiviDesignSystemAbility implements Ability { return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und ' . '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, ' - . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.'; + . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen. ' + . 'Filter: only (colors/variables/presets) und module (Presets eines Moduls).'; } public function inputSchema(): array { - return ['type' => 'object', 'properties' => [], 'additionalProperties' => false]; + return [ + 'type' => 'object', + 'properties' => [ + 'only' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => self::PARTS], + 'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer ' + . 'dabei.', + ], + 'module' => [ + 'type' => 'string', + 'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".', + ], + ], + 'additionalProperties' => false, + ]; } public function outputSchema(): ?array @@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability public function execute(array $input): mixed { $design = $this->reader->read(); + $only = is_array($input['only'] ?? null) && $input['only'] !== [] + ? array_values(array_intersect(self::PARTS, $input['only'])) + : self::PARTS; + $notes = [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ]; + $skipped = array_values(array_diff(self::PARTS, $only)); + if ($skipped !== []) { + $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.'; + } + + $presets = $design->presets(); + $module = is_string($input['module'] ?? null) ? trim($input['module']) : ''; + if ($module !== '') { + $name = str_contains($module, '/') ? $module : 'divi/' . $module; + $presets = array_values(array_filter( + $presets, + static fn (array $preset): bool => $preset['module'] === $name + )); + } + if (count($presets) > self::PRESET_LIMIT) { + $notes[] = sprintf( + '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', + self::PRESET_LIMIT, + count($presets) + ); + $presets = array_slice($presets, 0, self::PRESET_LIMIT); + } return [ - 'colors' => array_map( + 'colors' => in_array('colors', $only, true) ? array_map( static fn (array $color): array => $color + [ 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), ], $design->colors() - ), - 'variables' => array_map( + ) : [], + 'variables' => in_array('variables', $only, true) ? array_map( static fn (array $variable): array => $variable + ['placeholder' => '{{var:' . $variable['label'] . '}}'], $design->variables() - ), + ) : [], 'fonts' => $design->fonts(), 'sizes' => $design->sizes(), - 'presets' => $design->presets(), - 'notes' => [ - 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', - 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', - ], + 'presets' => in_array('presets', $only, true) ? $presets : [], + 'notes' => $notes, ]; } } diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index 425bff7..fd1f761 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability ], 'builder_version' => ['type' => ['string', 'null']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'global' => ['type' => ['object', 'null']], ], 'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], ]; @@ -128,6 +129,16 @@ final class DiviNodeGetAbility implements Ability $editable = $block->isDivi() && ! $block->isOpaque(); $version = $block->attrs()['builderVersion'] ?? null; + $global = NodeSummary::globalRef($block); + if ($global !== null) { + $global['hint'] = sprintf( + 'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); ' + . 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.', + $global['id'], + $global['id'] + ); + } + return [ 'post_id' => (int) $page->post->ID, 'tree_rev' => $page->treeRev(), @@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability 'children' => $editable ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) : [], + 'global' => $global, ]; } } diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 7151b50..70026d9 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability 'type' => 'boolean', 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', ], + 'global_id' => [ + 'type' => ['string', 'null'], + 'description' => 'Bibliotheks-ID eines globalen Moduls.', + ], ], 'required' => [ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', - 'truncated', + 'truncated', 'global_id', ], ], ], @@ -187,7 +191,8 @@ final class DiviOutlineAbility implements Ability if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' - . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; + . 'verschieben, duplizieren oder löschen. Globale Module [global ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_id – die Änderung wirkt auf alle Seiten.'; } return $hints; diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 465bb19..d3a0155 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; use stdClass; @@ -26,7 +27,7 @@ final class OutlineTest extends TestCase self::assertSame( ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3, - 'truncated' => false], + 'truncated' => false, 'global_id' => null], $rows[5] ); self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); @@ -40,6 +41,22 @@ final class OutlineTest extends TestCase ); } + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $outline = new Outline(); + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + + $byId = array_column($rows, null, 'id'); + self::assertSame('1058', $byId['g0']['global_id']); + self::assertSame('section', $byId['g0']['type']); + self::assertFalse($byId['g0']['editable']); + self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']); + self::assertSame('text', $byId['s0.r0.c0.g0']['type']); + self::assertNull($byId['s0']['global_id']); + self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows)); + } + public function testSubtreeAndDepthLimit(): void { $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); @@ -104,7 +121,7 @@ final class OutlineTest extends TestCase $text = (new Outline())->text((new Outline())->rows($index)); self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text); - self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text); + self::assertStringContainsString('s1.g1 row "Globale Zeile" [global 913] (nur lesbar)', $text); self::assertStringContainsString('s0 section [specialty]', $text); self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text); } diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php index 6ef4d0e..26b7cc7 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Tests\Support\DiviFixtures; @@ -41,4 +42,38 @@ final class DiviDesignSystemAbilityTest extends TestCase self::assertToolError($result, 'forbidden'); } + + public function testFiltersPartsAndPresetsOfOneModule(): void + { + Functions\when('current_user_can')->justReturn(true); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); + $ability = new DiviDesignSystemAbility($reader); + + $result = $ability->execute(['only' => ['presets'], 'module' => 'button']); + + self::assertSame([], $result['colors']); + self::assertSame([], $result['variables']); + self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id')); + self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module')))); + self::assertNotEmpty($result['fonts']['heading']); + self::assertContains('Nicht angefordert: colors, variables.', $result['notes']); + } + + public function testCapsThePresetList(): void + { + Functions\when('current_user_can')->justReturn(true); + $items = []; + for ($i = 0; $i < 250; ++$i) { + $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text']; + } + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []); + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn($design); + + $result = (new DiviDesignSystemAbility($reader))->execute([]); + + self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); + self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 11e7f0d..4a51280 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -123,4 +123,19 @@ final class DiviReadAbilitiesTest extends TestCase self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed'); self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found'); } + + public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); + + $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); + + self::assertSame('1058', $global['global']['id']); + self::assertSame('divi/section', $global['global']['block_name']); + self::assertStringContainsString('post_id 1058', $global['global']['hint']); + self::assertFalse($global['editable']); + self::assertNull($plain['global']); + } } From b389f983def941daba05eea2e865febd1b2e430e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:44:31 +0200 Subject: [PATCH 09/47] feat: render summary notes global modules it cannot show (F8) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Render/PageRenderSummaryAbility.php | 28 ++++++++++++++++- .../Render/PageRenderSummaryAbilityTest.php | 31 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php index 67be670..b111c95 100644 --- a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -19,6 +19,9 @@ defined('ABSPATH') || exit; */ final class PageRenderSummaryAbility implements Ability { + /** At most this many global-module notes per summary. */ + private const GLOBAL_NOTES = 20; + public function __construct(private ContentSchema $schema, private PageRenderer $renderer) { } @@ -40,7 +43,8 @@ final class PageRenderSummaryAbility implements Ability . 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Das Rendern führt die ' . 'üblichen Inhalts-Filter aus (Shortcodes, Divi, andere Plugins) und kann dabei Netzwerkanfragen ' . 'auslösen, z. B. oEmbed-Abrufe, die von WordPress oder anderen Plugins zwischengespeichert werden. ' - . 'Gut zum Prüfen nach Änderungen.'; + . 'Gut zum Prüfen nach Änderungen. Globale Module (Divi-Bibliothek) fehlen im Auszug und werden ' + . 'unter notes genannt.'; } public function inputSchema(): array @@ -104,10 +108,32 @@ final class PageRenderSummaryAbility implements Ability $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; } + foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) { + $notes[] = sprintf( + 'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi ' + . 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.', + $id, + $id + ); + } + return array_merge( ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], RenderSummary::fromHtml($this->renderer->render($post)), ['notes' => $notes] ); } + + /** + * IDs of the global modules in $content, found the way Divi finds them (a string scan for + * "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there). + * + * @return list + */ + private static function globalModuleIds(string $content): array + { + preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches); + + return array_values(array_unique($matches[1])); + } } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php index 62d4d8b..e154723 100644 --- a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -8,6 +8,8 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; use LIT\MCP\Integrations\WordPress\Render\RenderSummary; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\SchemaFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -138,4 +140,33 @@ final class PageRenderSummaryAbilityTest extends TestCase self::assertTrue($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); } + + public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void + { + $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn('

Text

'); + $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer); + + $notes = $ability->execute(['post_id' => 7])['notes']; + + $globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul'))); + self::assertCount(2, $globals); + self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]); + self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]); + } + + public function testLibraryEntriesAreNoManagedContent(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout'])); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + $ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class)); + + self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found'); + } } From ce1ad7c6c35d9382e6489c79e91f650c7205af5d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:51:09 +0200 Subject: [PATCH 10/47] feat: Divi adapters for roles and library with a live compatibility check Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Core/EnvironmentProbe.php | 20 ++- src/Integrations/Core/SiteStatusAbility.php | 3 +- src/Integrations/Divi/DiviCompatibility.php | 61 +++++++ .../Divi/Library/DiviLibraryStore.php | 152 ++++++++++++++++++ src/Integrations/Divi/Library/DiviRoles.php | 56 +++++++ .../Divi/Pages/DynamicContentMap.php | 3 +- .../Core/EnvironmentProbeTest.php | 29 ++++ .../Divi/DiviCompatibilityTest.php | 41 +++++ .../Divi/Library/DiviLibraryStoreTest.php | 102 ++++++++++++ .../Divi/Library/DiviRolesTest.php | 29 ++++ 10 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 src/Integrations/Divi/DiviCompatibility.php create mode 100644 src/Integrations/Divi/Library/DiviLibraryStore.php create mode 100644 src/Integrations/Divi/Library/DiviRoles.php create mode 100644 tests/Unit/Integrations/Core/EnvironmentProbeTest.php create mode 100644 tests/Unit/Integrations/Divi/DiviCompatibilityTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/DiviRolesTest.php diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php index aca8d8e..b2984ec 100644 --- a/src/Integrations/Core/EnvironmentProbe.php +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -4,14 +4,21 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Core; +use LIT\MCP\Integrations\Divi\DiviCompatibility; + defined('ABSPATH') || exit; /** - * Detects the themes/plugins LIT MCP integrates with. + * Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing + * (DiviCompatibility). */ class EnvironmentProbe { - /** @return list */ + public function __construct(private ?DiviCompatibility $divi = null) + { + } + + /** @return list}> */ public function components(): array { $theme = wp_get_theme(); @@ -22,16 +29,23 @@ class EnvironmentProbe $tecActive = class_exists('Tribe__Events__Main'); return [ - ['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion], + [ + 'id' => 'divi', + 'active' => $isDivi, + 'version' => $diviVersion, + 'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [], + ], [ 'id' => 'pods', 'active' => defined('PODS_VERSION'), 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + 'problems' => [], ], [ 'id' => 'the-events-calendar', 'active' => $tecActive, 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, + 'problems' => [], ], ]; } diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php index 2313c7c..e1212fb 100644 --- a/src/Integrations/Core/SiteStatusAbility.php +++ b/src/Integrations/Core/SiteStatusAbility.php @@ -30,7 +30,8 @@ final class SiteStatusAbility implements Ability public function description(): string { return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' - . 'erkannte Integrationen (Divi, Pods, The Events Calendar).'; + . 'erkannte Integrationen (Divi, Pods, The Events Calendar). ' + . 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.'; } public function inputSchema(): array diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php new file mode 100644 index 0000000..434ad87 --- /dev/null +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -0,0 +1,61 @@ +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list Labels of the checks that failed or threw. */ + public function problems(): array + { + $problems = []; + foreach ($this->checks ?? self::defaults() as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $problems[] = (string) $label; + } + } + + return $problems; + } + + /** @return array */ + public static function defaults(): array + { + return [ + 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), + 'DynamicContentOptions (Dynamic Content)' => + static fn (): bool => class_exists(DynamicContentMap::REGISTRY), + 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), + 'Post-Typ et_pb_layout (Bibliothek)' => + static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), + 'Taxonomien scope, layout_type, module_width (Bibliothek)' => + static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], + 'GlobalLayout::is_global_layout_template (globale Module)' => + static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), + 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), + ]; + } +} diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php new file mode 100644 index 0000000..c393d22 --- /dev/null +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -0,0 +1,152 @@ +termSlugs($id, 'scope'), true); + } + + /** "layout", "section", "row" or "module"; null when the entry has no known type. */ + public function layoutType(int $id): ?string + { + foreach ($this->termSlugs($id, 'layout_type') as $slug) { + if (in_array($slug, self::LAYOUT_TYPES, true)) { + return $slug; + } + } + + return null; + } + + /** @return list */ + public function termSlugs(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy)); + } + + /** @return list */ + public function termNames(int $id, string $taxonomy): array + { + return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy)); + } + + /** + * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). + * + * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters + * scope: "global" or "normal"; category: name of a layout_category term. + * @return list + */ + public function find(array $filters, int $limit, int $offset): array + { + $taxQuery = []; + if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) { + $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]]; + } + if (($filters['scope'] ?? '') === 'global') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']]; + } elseif (($filters['scope'] ?? '') === 'normal') { + $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN']; + } + if (($filters['category'] ?? '') !== '') { + $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]]; + } + + $args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + if (($filters['search'] ?? '') !== '') { + $args['s'] = $filters['search']; + } + if ($taxQuery !== []) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. + $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery); + } + + return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post)); + } + + /** + * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string, + * categories: list, tags: list, modified: string} + */ + public function describe(WP_Post $post): array + { + $id = (int) $post->ID; + $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true); + + return [ + 'id' => $id, + 'title' => (string) $post->post_title, + 'layout_type' => $this->layoutType($id), + 'scope' => $this->isGlobal($id) ? 'global' : 'normal', + 'module_type' => $moduleType === '' ? null : $moduleType, + 'categories' => $this->termNames($id, 'layout_category'), + 'tags' => $this->termNames($id, 'layout_tag'), + 'modified' => (string) $post->post_modified_gmt, + ]; + } + + /** + * Library taxonomies not registered in this request (F2: Divi registers them in + * ET_Builder_Library; fixture finding 6: not in every context). + * + * @return list + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** @return list */ + private function terms(int $id, string $taxonomy): array + { + $terms = get_the_terms($id, $taxonomy); + + return is_array($terms) + ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term)) + : []; + } +} diff --git a/src/Integrations/Divi/Library/DiviRoles.php b/src/Integrations/Divi/Library/DiviRoles.php new file mode 100644 index 0000000..1adc241 --- /dev/null +++ b/src/Integrations/Divi/Library/DiviRoles.php @@ -0,0 +1,56 @@ + 'Divi-Bibliothek', + 'save_library' => 'In der Bibliothek speichern', + 'add_library' => 'Aus der Bibliothek hinzufügen', + 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', + 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + ]; + + /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ + public function __construct(private ?Closure $check = null) + { + } + + public function allows(string $capability): bool + { + if ($this->check !== null) { + return ($this->check)($capability); + } + + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability); + } + + /** Null when the role allows $capability, else a forbidden error naming the option. */ + public function refusal(string $capability): ?WP_Error + { + if ($this->allows($capability)) { + return null; + } + + return Errors::forbidden( + sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 10f0c6a..941ef6c 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -31,7 +31,8 @@ defined('ABSPATH') || exit; */ class DynamicContentMap { - private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; + /** Divi's dynamic-content registry class; also checked by DiviCompatibility. */ + public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; /** * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. diff --git a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php new file mode 100644 index 0000000..2dfb442 --- /dev/null +++ b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php @@ -0,0 +1,29 @@ +allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])); + $components = array_column($probe->components(), null, 'id'); + + self::assertSame(['GlobalLayout'], $components['divi']['problems']); + self::assertSame([], $components['pods']['problems']); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php new file mode 100644 index 0000000..f3840e7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php @@ -0,0 +1,41 @@ + static fn (): bool => true, + 'B' => static fn (): bool => false, + 'C' => static function (): bool { + throw new RuntimeException('Divi changed'); + }, + ]); + + self::assertSame(['B', 'C'], $compatibility->problems()); + } + + public function testDefaultsCoverTheDiviInternalsInUse(): void + { + self::assertSame( + [ + 'ModuleRegistration (Modul-Katalog)', + 'DynamicContentOptions (Dynamic Content)', + 'et_pb_is_allowed (Divi-Rollen)', + 'Post-Typ et_pb_layout (Bibliothek)', + 'Taxonomien scope, layout_type, module_width (Bibliothek)', + 'GlobalLayout::is_global_layout_template (globale Module)', + 'ET_Core_PageResource (CSS-Cache)', + ], + array_keys(DiviCompatibility::defaults()) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php new file mode 100644 index 0000000..9304c4d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -0,0 +1,102 @@ + [ + 'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + 'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])], + ], + 1055 => [ + 'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])], + 'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])], + ], + 1057 => [], + ]; + Functions\when('get_the_terms')->alias( + static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : '' + ); + } + + public function testGlobalScopeAndLayoutTypeFromTheTerms(): void + { + $store = new DiviLibraryStore(); + + self::assertTrue($store->isGlobal(1058)); + self::assertFalse($store->isGlobal(1055)); + self::assertSame('section', $store->layoutType(1058)); + self::assertNull($store->layoutType(1057)); + self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category')); + } + + public function testDescribesAnEntry(): void + { + $post = new WP_Post([ + 'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul', + 'post_modified_gmt' => '2026-09-26 20:56:29', + ]); + + self::assertSame( + [ + 'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal', + 'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29', + ], + (new DiviLibraryStore())->describe($post) + ); + } + + public function testFindQueriesPublishedEntriesWithFilters(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find( + ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'], + 20, + 40 + ); + + self::assertSame('et_pb_layout', $captured['post_type']); + self::assertSame('publish', $captured['post_status']); + self::assertSame(21, $captured['posts_per_page']); + self::assertSame(40, $captured['offset']); + self::assertSame('Kopf', $captured['s']); + self::assertSame( + [ + 'relation' => 'AND', + ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']], + ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'], + ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']], + ], + $captured['tax_query'] + ); + } + + public function testReportsMissingTaxonomies(): void + { + Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); + + self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php new file mode 100644 index 0000000..876e5cf --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/DiviRolesTest.php @@ -0,0 +1,29 @@ + $cap !== 'edit_global_library'); + + self::assertTrue($roles->allows('divi_library')); + self::assertNull($roles->refusal('divi_library')); + $refusal = $roles->refusal('edit_global_library'); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message()); + self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message()); + } + + public function testWithoutDiviNothingIsAllowed(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new DiviRoles())->allows('divi_library')); + } +} From 2700fd615a623014fa2d9d8d7201aa1f93eb1361 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:53:38 +0200 Subject: [PATCH 11/47] feat: global instance in the builder's format, deglobalize, library content rules Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/BlockTree/Block.php | 24 ++++ src/Integrations/Divi/BlockTree/Container.php | 31 ++++- .../Divi/BlockTree/GlobalInstance.php | 115 ++++++++++++++++++ .../Divi/BlockTree/LibraryContent.php | 107 ++++++++++++++++ src/Integrations/Divi/Compose/TreeEditor.php | 8 +- tests/Fixtures/divi/stage3/README.md | 1 + .../Integrations/Divi/BlockTree/BlockTest.php | 28 +++++ .../Divi/BlockTree/ContainerTest.php | 49 ++++++++ .../Divi/BlockTree/GlobalInstanceTest.php | 106 ++++++++++++++++ .../Divi/BlockTree/LibraryContentTest.php | 103 ++++++++++++++++ .../Divi/Compose/TreeEditorTest.php | 19 +++ 11 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/Divi/BlockTree/GlobalInstance.php create mode 100644 src/Integrations/Divi/BlockTree/LibraryContent.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php index 89a1872..9266bf4 100644 --- a/src/Integrations/Divi/BlockTree/Block.php +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -141,6 +141,30 @@ final class Block extends Container return clone $this; } + /** + * A deep copy of this block under another name and attributes: opener and closer are + * regenerated, children and the text between them are kept, the void flag too. Opaque exactly + * when the Parser would make it so. GlobalInstance uses it to turn a library root into an + * instance and back. + * + * @param array $attrs + */ + public function copyAs(string $name, array $attrs): self + { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name)); + } + + $copy = clone $this; + $copy->name = $name; + $copy->attrs = self::deepCloneAttrs($attrs); + $copy->rawOpen = null; + $copy->rawClose = null; + $copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + + return $copy; + } + public function __clone() { foreach ($this->parts as $position => $part) { diff --git a/src/Integrations/Divi/BlockTree/Container.php b/src/Integrations/Divi/BlockTree/Container.php index 05447ed..5f79f14 100644 --- a/src/Integrations/Divi/BlockTree/Container.php +++ b/src/Integrations/Divi/BlockTree/Container.php @@ -93,12 +93,41 @@ abstract class Container return $index; } + /** Puts $new where the child $old is; the text around it stays. */ + public function replaceChild(Block $old, Block $new): void + { + $this->guardMutable(); + $this->parts[$this->partPosition($old)] = $new; + $this->touched(); + } + + /** + * "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved + * through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture + * finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched, + * and so do opaque blocks (global instances keep Divi's bytes). + */ + public function unifyLineBreaks(): void + { + if ($this instanceof Block && $this->isOpaque()) { + return; + } + + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $part->unifyLineBreaks(); + } elseif (trim($part) === '' && str_contains($part, "\r\n")) { + $this->parts[$position] = str_replace("\r\n", "\n", $part); + } + } + } + /** Called after the list of children changed. */ protected function touched(): void { } - /** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */ + /** insertChild()/removeChild()/replaceChild() are the tools' structural edits – never on an opaque block. */ private function guardMutable(): void { if ($this instanceof Block && $this->isOpaque()) { diff --git a/src/Integrations/Divi/BlockTree/GlobalInstance.php b/src/Integrations/Divi/BlockTree/GlobalInstance.php new file mode 100644 index 0000000..2b56f44 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/GlobalInstance.php @@ -0,0 +1,115 @@ +name() !== self::BLOCK) { + return $block->name(); + } + + $name = $block->attrs()['blockName'] ?? ''; + + return is_string($name) ? $name : ''; + } + + /** + * The instance the Visual Builder writes for library entry $libraryId whose content root is + * $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested; + * fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and + * localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out + * when empty –, the root's children as snapshot with "globalParent": "" appended to every + * block below. A void root gives a void instance. $root is not changed. + */ + public static function of(Block $root, int $libraryId): Block + { + $id = (string) $libraryId; + $local = $root->attrs(); + ksort($local); + $attrs = ['globalModule' => $id, 'blockName' => $root->name()]; + if ($local !== []) { + $attrs['localAttrs'] = $local; + } + + $instance = $root->copyAs(self::BLOCK, $attrs); + foreach ($instance->children() as $child) { + self::markParent($child, $id); + } + + return $instance; + } + + /** + * Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1 + * PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every + * divi/global-layout at or below $block becomes its blockName with localAttrs as attributes, + * and globalParent disappears from every block. Other opaque blocks stay, and so does an instance + * without a usable blockName. Changes $block in place (pass a copy) and returns the result – a + * new block when $block itself was an instance. + */ + public static function deglobalize(Block $block): Block + { + if ($block->name() === self::BLOCK) { + $name = self::effectiveName($block); + if ($name === '') { + return $block; + } + $local = $block->attrs()['localAttrs'] ?? []; + try { + $block = $block->copyAs($name, is_array($local) ? $local : []); + } catch (InvalidArgumentException) { + return $block; // Stored blockName is no block name: the instance stays opaque. + } + } + + if ($block->isOpaque()) { + return $block; + } + + if (array_key_exists('globalParent', $block->attrs())) { + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $block->setAttrs($attrs); + } + + foreach ($block->children() as $child) { + $plain = self::deglobalize($child); + if ($plain !== $child) { + $block->replaceChild($child, $plain); + } + } + + return $block; + } + + private static function markParent(Block $block, string $id): void + { + if ($block->isOpaque()) { + return; + } + + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $attrs['globalParent'] = $id; + $block->setAttrs($attrs); + foreach ($block->children() as $child) { + self::markParent($child, $id); + } + } +} diff --git a/src/Integrations/Divi/BlockTree/LibraryContent.php b/src/Integrations/Divi/BlockTree/LibraryContent.php new file mode 100644 index 0000000..6235a40 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/LibraryContent.php @@ -0,0 +1,107 @@ +parse($content); + $document->unifyLineBreaks(); + + return $document; + } + + /** post_content of a library entry holding $node, framed like the builder's "add to library". */ + public static function wrap(Block $node): string + { + return '' . Serializer::block($node) . ''; + } + + /** @return list Top-level nodes: the children of the first divi/placeholder, else of the document. */ + public static function roots(Document $document): array + { + foreach ($document->children() as $block) { + if ($block->isPlaceholder()) { + return $block->children(); + } + } + + return $document->children(); + } + + /** Whether $block is or contains a global module. */ + public static function containsGlobal(Block $block): bool + { + if ($block->isGlobal()) { + return true; + } + + foreach ($block->children() as $child) { + if (self::containsGlobal($child)) { + return true; + } + } + + return false; + } + + /** + * Why the roots $after may not replace $before, or null. Instances on pages expect their + * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the + * same type; a layout entry (or one with several roots) holds sections only. A global entry + * contains no global modules (F10: Divi forbids nesting). + * + * @param list $before + * @param list $after + */ + public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string + { + if ($after === []) { + return 'Der Bibliothekseintrag darf nicht leer werden.'; + } + + if ($layoutType === 'layout' || count($before) !== 1) { + foreach ($after as $root) { + if (GlobalInstance::effectiveName($root) !== 'divi/section') { + return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.'; + } + } + } else { + $expected = GlobalInstance::effectiveName($before[0]); + if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) { + return sprintf( + 'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).', + $expected + ); + } + } + + if ($global) { + foreach ($after as $root) { + if (self::containsGlobal($root)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung).'; + } + } + } + + return null; + } +} diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php index 4da8a96..dc8bf68 100644 --- a/src/Integrations/Divi/Compose/TreeEditor.php +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Container; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; defined('ABSPATH') || exit; @@ -14,7 +15,8 @@ defined('ABSPATH') || exit; /** * Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after, * optional index), move, remove, duplicate – always checked against StructureRules. Works on - * the tree only; saving is DiviWriter's job. Pure PHP. + * the tree only; saving is DiviWriter's job. Global instances count as their root type + * (blockName, GlobalInstance::effectiveName()). Pure PHP. */ final class TreeEditor { @@ -79,7 +81,7 @@ final class TreeEditor public function insert(array $position, array $blocks): ?string { foreach ($blocks as $block) { - $violation = $this->rules->violation($position['parent'], $block->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); if ($violation !== null) { return $violation; } @@ -104,7 +106,7 @@ final class TreeEditor } } - $violation = $this->rules->violation($position['parent'], $node->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node)); if ($violation !== null) { return $violation; } diff --git a/tests/Fixtures/divi/stage3/README.md b/tests/Fixtures/divi/stage3/README.md index 7a40732..e81f8f1 100644 --- a/tests/Fixtures/divi/stage3/README.md +++ b/tests/Fixtures/divi/stage3/README.md @@ -79,6 +79,7 @@ Im Body-Layout: Überschrift = `post_title`, Text = `post_meta_key` mit manuelle 6. **TB-Layouts haben keinen `layout_type`-Term**, obwohl `et_theme_builder_insert_layout` ihn setzen will (`theme-builder.php:1663`). In `wp_term_relationships` steht für 1061–1063 nichts; vermutlich ist die Taxonomie im admin-ajax-Aufruf nicht registriert (Registrierung über `ET_Builder_Library`, `builder/feature/Library.php:397`) – die Ursache ist nicht weiter geprüft. Nicht auf Terme bei TB-Layouts verlassen. 7. **TB-Bedingungen** stehen als mehrfaches Meta `_et_use_on` / `_et_exclude_from` mit IDs wie `singular:post_type:page:id:1053`. Die Vorlagenzuordnung ist `_et_template` (mehrfach) am `et_theme_builder`-Post; Vorlagen-Titel der Standardvorlage ist übersetzt („Standard-Website-Template“). 8. TB-Layouts heißen nach dem Anlegen alle „Theme Builder Layout“ mit Slug `theme-builder-layout` (in jedem Post-Typ gleich); erst der Titel im Builder benennt sie um. `_et_pb_post_settings_title` spiegelt den Titel. +9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`. ## Wirkung auf die Live-Site diff --git a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php index ed2fffc..0c1605e 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; use stdClass; @@ -74,4 +75,31 @@ final class BlockTest extends TestCase self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']); self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']); } + + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame( + "\n\n", + Serializer::block($row) + ); + self::assertFalse($row->copyAs('divi/row', [])->isOpaque()); + } + + public function testCopyAsRefusesInvalidNames(): void + { + $this->expectException(InvalidArgumentException::class); + + Block::create('divi/text')->copyAs('Divi Text', []); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php index 8fd85d9..13aecc8 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; @@ -51,4 +52,52 @@ final class ContainerTest extends TestCase $this->expectException(LogicException::class); $void->append('text'); } + + public function testReplaceChildKeepsTheTextAround(): void + { + $document = (new Parser())->parse("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } + + /** Global instances stay opaque: their bytes are Divi's, even the whitespace inside. */ + public function testUnifyLineBreaksLeavesOpaqueBlocksAlone(): void + { + $markup = "\r\n\r\n" + . "\r\n"; + $document = (new Parser())->parse($markup); + + $document->unifyLineBreaks(); + + self::assertSame(substr($markup, 0, -2) . "\n", Serializer::document($document)); + } + + public function testReplaceChildRefusesOnAnOpaqueBlock(): void + { + $document = (new Parser())->parse( + '' + ); + $instance = $document->children()[0]; + + $this->expectException(LogicException::class); + + $instance->replaceChild($instance->children()[0], Block::create('divi/button')); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php new file mode 100644 index 0000000..5c4676e --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php @@ -0,0 +1,106 @@ +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + 'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'], + ]; + } + + /** @dataProvider instances */ + public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''; + $instance = (new Parser())->parse($markup)->children()[0]; + + $result = GlobalInstance::deglobalize($instance); + + self::assertSame($instance, $result); + self::assertSame($markup, Serializer::block($result)); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php new file mode 100644 index 0000000..ccb8996 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php @@ -0,0 +1,103 @@ + */ + public static function savedNodes(): array + { + return [ + 'section' => ['s1', 'library--library-abschnitt.html'], + 'row' => ['s1.r0', 'library--library-zeile.html'], + 'module' => ['s1.r0.c0.m0', 'library--library-modul.html'], + ]; + } + + /** + * The builder's "add to library" wraps the node in divi/placeholder (F4); the fixtures only + * differ by the CRLF of the form upload (finding 9). + * + * @dataProvider savedNodes + */ + public function testWrapMatchesTheBuilderEntry(string $id, string $file): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/' . $file)), + LibraryContent::wrap($index->find($id)) + ); + } + + public function testLoadUnifiesLineBreaksAndRootsSkipThePlaceholder(): void + { + $document = LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/library--layout-ganze-seite.html')); + + self::assertStringNotContainsString("\r", Serializer::document($document)); + self::assertSame( + ['divi/section', 'divi/section', 'divi/section'], + array_map(static fn (Block $block): string => $block->name(), LibraryContent::roots($document)) + ); + self::assertSame( + ['divi/text'], + array_map( + static fn (Block $block): string => $block->name(), + LibraryContent::roots((new Parser())->parse('')) + ) + ); + } + + public function testContainsGlobal(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertTrue(LibraryContent::containsGlobal($index->find('s0'))); + self::assertFalse(LibraryContent::containsGlobal($index->find('s1'))); + } + + public function testShapeViolations(): void + { + $section = Block::create('divi/section'); + $row = Block::create('divi/row'); + $instance = GlobalInstance::of(Block::create('divi/text'), 9); + $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [ + Block::create('divi/column', [], [$instance]), + ])]); + + self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false)); + self::assertStringContainsString( + 'nicht leer', + (string) LibraryContent::shapeViolation([$section], [], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$row], null, false) + ); + self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false)); + self::assertStringContainsString( + 'nur Abschnitte', + (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false) + ); + self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false)); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php index c0da09e..ba38032 100644 --- a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; @@ -201,4 +202,22 @@ final class TreeEditorTest extends TestCase self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); } + + /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */ + public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void + { + $document = $this->document('stage3/page--globale-instanzen.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1']))); + + $index = NodeIndex::build($document); + $module = GlobalInstance::of(Block::create('divi/text'), 1059); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module])); + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)]) + ); + } } From 48ecc627aa538281a924fc6f51d689d5d1c621b0 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:02:25 +0200 Subject: [PATCH 12/47] feat: Divi read tools accept library entries (library access, E1/E3) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/ExposurePolicy.php | 9 ++ src/Integrations/Divi/DiviIntegration.php | 22 +++- .../Divi/Library/LibraryAccess.php | 98 +++++++++++++++ src/Integrations/Divi/Pages/DiviPage.php | 14 ++- .../Divi/Pages/DiviPageLoader.php | 26 +++- src/Integrations/WordPress/PostGuard.php | 8 +- src/Plugin.php | 7 +- tests/Support/LibraryFixtures.php | 118 ++++++++++++++++++ tests/Unit/Abilities/ExposurePolicyTest.php | 10 ++ .../Integrations/Divi/DiviIntegrationTest.php | 21 ++++ .../Divi/Library/LibraryAccessTest.php | 88 +++++++++++++ .../Divi/Pages/DiviPageLoaderTest.php | 50 ++++++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 29 +++++ 13 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryAccess.php create mode 100644 tests/Support/LibraryFixtures.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 07aa2f2..a5c8c3c 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -181,6 +181,15 @@ final class ExposurePolicy return isset($this->readonlyGroups[$group]); } + /** + * Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The + * group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9). + */ + public function groupEnabled(string $group): bool + { + return $this->groupMode($group) !== self::DISABLED; + } + public function defaultMode(Ability $ability): string { return $ability->isDirect() ? self::DIRECT : self::INDIRECT; diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index f825fd6..80508a7 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -6,8 +6,12 @@ namespace LIT\MCP\Integrations\Divi; use Closure; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; +use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; @@ -39,11 +43,23 @@ final class DiviIntegration implements Integration { private DiviModules $modules; private DesignSystemReader $design; + private LibraryAccess $access; - public function __construct(private ContentSchema $schema, private ChangeLog $log) - { + /** + * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries + * (group "Divi-Bibliothek" writable); null = always. + * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group + * "Divi-Bibliothek" not disabled, ruling P9); null = always. + */ + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + ?Closure $libraryWritable = null, + ?Closure $libraryVisible = null + ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); + $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible); } public function id(): string @@ -80,7 +96,7 @@ final class DiviIntegration implements Integration public function abilities(): array { - $loader = new DiviPageLoader($this->schema); + $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); $composition = new DiviComposition($this->modules, $this->design); $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php new file mode 100644 index 0000000..fba31c5 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -0,0 +1,98 @@ +store; + } + + public function roles(): DiviRoles + { + return $this->roles; + } + + /** + * The library entry $postId after the checks for reading, or their refusal; null when $postId + * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling + * P9), an entry is refused exactly like an ID that does not exist – before any rights check. + */ + public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null + { + $id = PostGuard::id($postId); + $post = $id === null ? null : get_post($id); + if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) { + return null; + } + + if ($this->visible !== null && ! ($this->visible)()) { + return PostGuard::notFound((int) $post->ID); + } + + return $this->canRead($post) ?? $post; + } + + /** Listing the library: edit_posts and the Divi role option divi_library. */ + public function canList(): ?WP_Error + { + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.'); + } + + return $this->roles->refusal('divi_library'); + } + + /** Reading one entry: canList() and edit_post on the entry. */ + public function canRead(WP_Post $item): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + return current_user_can('edit_post', (int) $item->ID) + ? null + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); + } + + public function isGlobal(WP_Post $item): bool + { + return $this->store->isGlobal((int) $item->ID); + } +} diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php index 6c4a10a..318f4f4 100644 --- a/src/Integrations/Divi/Pages/DiviPage.php +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -21,10 +21,17 @@ final class DiviPage public function __construct( public readonly WP_Post $post, public readonly Document $document, - public readonly NodeIndex $index + public readonly NodeIndex $index, + private bool $libraryItem = false ) { } + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ + public function isLibraryItem(): bool + { + return $this->libraryItem; + } + public function treeRev(): string { return Rev::of((string) $this->post->post_content); @@ -40,7 +47,10 @@ final class DiviPage public function editUrl(): string { - return self::builderUrl($this->post); + // A library entry has no public permalink (public: false); Divi's library list opens it here. + return $this->libraryItem + ? (string) get_edit_post_link($this->post, 'raw') + : self::builderUrl($this->post); } /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */ diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 57ceb6d..5bcd09f 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Log; use WP_Error; +use WP_Post; defined('ABSPATH') || exit; /** - * Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the - * block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint – - * spec §3.4: Divi 4 content is kept, never edited. + * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other + * entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4 + * shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content + * is kept, never edited. */ final class DiviPageLoader { public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die ' . 'Änderung mit der neuen tree_rev wiederholen.'; - public function __construct(private ContentSchema $schema, private Parser $parser = new Parser()) + public function __construct( + private ContentSchema $schema, + private Parser $parser = new Parser(), + private ?LibraryAccess $library = null + ) { + } + + /** The library access the Divi tools use, null when library entries are not reachable. */ + public function library(): ?LibraryAccess { + return $this->library; } /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - $post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + // Library entries (E1): only for reading so far, and only with a library access. + $item = $forWrite ? null : $this->library?->item($postId, false); + $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; } @@ -64,7 +78,7 @@ final class DiviPageLoader ); } - return new DiviPage($post, $document, NodeIndex::build($document)); + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); } /** The entry loaded for writing and checked by writable() – the refusal of either step. */ diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index cf88bf4..1cc8be4 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -45,7 +45,7 @@ final class PostGuard $post = get_post($id); if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { - return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + return self::notFound($id); } if (! current_user_can('edit_post', $id)) { @@ -63,6 +63,12 @@ final class PostGuard return $post; } + /** The refusal for an ID that names no entry the tools manage – the same for every such ID. */ + public static function notFound(int $id): WP_Error + { + return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + } + /** * A scheduled entry whose publication time has passed (WordPress' cron missed it) or is * less than a minute away: wp_insert_post() turns "future" into "publish" on any save when diff --git a/src/Plugin.php b/src/Plugin.php index daef929..6747594 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; @@ -71,9 +72,13 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); - $divi = new DiviIntegration($schema, $changeLog); $registry = null; $policy = null; + // Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries. + $libraryVisible = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP); + }; + $divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible); // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // when registry and policy exist), so the Divi group's switches stop every Divi layout write. $diviWritable = static function () use (&$registry, &$policy): bool { diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php new file mode 100644 index 0000000..df9fd56 --- /dev/null +++ b/tests/Support/LibraryFixtures.php @@ -0,0 +1,118 @@ + ID => [fixture file, layout type, global]. */ + public const ITEMS = [ + 1054 => ['library--layout-ganze-seite.html', 'layout', false], + 1055 => ['library--library-abschnitt.html', 'section', false], + 1056 => ['library--library-zeile.html', 'row', false], + 1057 => ['library--library-modul.html', 'module', false], + 1058 => ['library-global--globaler-abschnitt.html', 'section', true], + 1059 => ['library-global--globales-modul.html', 'module', true], + ]; + + /** @param array $fields */ + public static function item(int $id, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => $id, + 'post_type' => DiviLibraryStore::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => '[Fixture] ' . $id, + 'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]), + ], $fields)); + } + + /** @param array $fields */ + public static function page(string $content, array $fields = []): WP_Post + { + return new WP_Post(array_merge([ + 'ID' => self::PAGE, + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => 'Start', + 'post_content' => $content, + ], $fields)); + } + + /** + * WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied. + * + * @param list $posts + * @param list $denied + */ + public static function given(array $posts, array $denied = []): void + { + $byId = []; + foreach ($posts as $post) { + $byId[(int) $post->ID] = $post; + } + Functions\when('get_post')->alias( + static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true) + ); + Functions\when('get_post_type_object')->justReturn(null); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf( + 'https://kunde.test/wp-admin/post.php?post=%d&action=edit', + $post instanceof WP_Post ? $post->ID : (int) $post + )); + Functions\when('add_query_arg')->alias( + static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value + ); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + } + + /** A store that answers isGlobal()/layoutType() from ITEMS (no database). */ + public static function store(): DiviLibraryStore + { + $store = Mockery::mock(DiviLibraryStore::class)->makePartial(); + $store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false); + $store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null); + + return $store; + } + + /** @param list $deniedRoles Divi role options the user lacks. */ + public static function roles(array $deniedRoles = []): DiviRoles + { + return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true)); + } + + /** @param list $deniedRoles */ + public static function access( + array $deniedRoles = [], + bool $writable = true, + ?DiviLibraryStore $store = null + ): LibraryAccess { + return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); + } + + public static function loader(?LibraryAccess $access = null): DiviPageLoader + { + return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); + } +} diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index 7f8b8e1..5951d2f 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -200,6 +200,16 @@ final class ExposurePolicyTest extends TestCase self::assertSame([], $policy->directNames([$update, $delete, $outline])); } + /** Ruling P9: only a group disabled as a whole is switched off; "nur lesen" still lets it read. */ + public function testGroupEnabledUnlessTheWholeGroupIsDisabled(): void + { + self::assertTrue((new ExposurePolicy())->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@readonly:divi-library' => true]))->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupEnabled('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupEnabled('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi' => 'disabled']))->groupEnabled('divi-library')); + } + public function testSanitizeValidatesGroupEntries(): void { self::assertSame( diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 97bdb75..389e5b0 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -9,8 +9,10 @@ use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WP_Error; final class DiviIntegrationTest extends TestCase { @@ -55,4 +57,23 @@ final class DiviIntegrationTest extends TestCase self::assertInstanceOf(DiviContentComposer::class, $composer); self::assertFalse($composer->available(), 'no Divi classes in unit tests'); } + + /** E1/E3: the Divi read tools reach library entries; ruling P9: a hidden library leaves them not found. */ + public function testTheReadToolsReachTheLibraryUnlessItIsHidden(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + $outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]); + + // No Divi in unit tests: DiviRoles allows nothing, so a reachable entry is refused, not missing. + $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static fn (): bool => true); + self::assertToolError($outline($visible), 'forbidden'); + self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'forbidden'); + + $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false); + $refusal = $outline($hidden); + self::assertInstanceOf(WP_Error::class, $refusal); + self::assertToolError($refusal, 'not_found'); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php new file mode 100644 index 0000000..e76d012 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -0,0 +1,88 @@ +item(1055, false)); + self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business'); + self::assertNull($access->item(9999, false)); + self::assertNull($access->item('abc', false)); + self::assertNull($access->canList()); + } + + public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void + { + $item = LibraryFixtures::item(1055); + + LibraryFixtures::given([$item], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item]); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden'); + + LibraryFixtures::given([$item], ['edit_post']); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, 'forbidden'); + self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message()); + } + + public function testTrashedItemsCanBeRead(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'trash']); + LibraryFixtures::given([$item]); + + self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false)); + } + + public function testGlobalFromTheStore(): void + { + $access = LibraryFixtures::access(); + + self::assertTrue($access->isGlobal(LibraryFixtures::item(1058))); + self::assertFalse($access->isGlobal(LibraryFixtures::item(1055))); + } + + public function testRefusalsNameNeitherTitleNorContent(): void + { + $item = LibraryFixtures::item(1055, ['post_status' => 'private', 'post_title' => 'Geheimer Entwurf']); + + foreach ([['edit_posts'], ['edit_post']] as $denied) { + LibraryFixtures::given([$item], $denied); + $refusal = LibraryFixtures::access()->item(1055, false); + self::assertToolError($refusal, 'forbidden'); + self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); + self::assertStringNotContainsString('Geheimer Entwurf', (string) wp_json_encode($refusal->get_error_data())); + } + } + + /** Ruling P9: with the group "Divi-Bibliothek" disabled, library IDs read like IDs that do not exist. */ + public function testAHiddenLibraryAnswersNotFoundLikeAMissingEntry(): void + { + $item = LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf']); + LibraryFixtures::given([$item, LibraryFixtures::page('')], ['edit_post']); + $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); + + foreach ([false, true] as $forWrite) { + $refusal = $hidden->item(1055, $forWrite); + self::assertToolError($refusal, 'not_found'); + self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); + self::assertStringNotContainsString('Bibliothek', $refusal->get_error_message()); + } + self::assertNull($hidden->item(LibraryFixtures::PAGE, false), 'pages stay PostGuard\'s business'); + self::assertNull($hidden->item(9999, false)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 5f2b13e..3e9824f 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -7,6 +7,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Tests\Support\LibraryFixtures; +use Mockery; use LIT\MCP\Tests\Unit\TestCase; use WP_Error; @@ -96,4 +101,49 @@ final class DiviPageLoaderTest extends TestCase DiviFixtures::givenPost("\n

Gutenberg

\n"); self::assertToolError($loader->loadWritable(7), 'unsupported'); } + + public function testLoadsALibraryItemForReading(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + + $page = LibraryFixtures::loader()->load(1058, false); + + self::assertInstanceOf(DiviPage::class, $page); + self::assertTrue($page->isLibraryItem()); + self::assertSame('divi/section', $page->index->find('s0')?->name()); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl()); + } + + public function testLibraryItemsNeedTheLibraryAccess(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); + self::assertToolError( + (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), + 'forbidden' + ); + } + + public function testLibraryItemsAreNotWritableYet(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false); + + $result = (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()))->load(1058, true); + + self::assertToolError($result, 'not_found'); + } + + /** Ruling P9: the group "Divi-Bibliothek" disabled hides library entries from the Divi read tools. */ + public function testAHiddenLibraryLeavesLibraryItemsNotFound(): void + { + LibraryFixtures::given([LibraryFixtures::item(1058)]); + $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); + + self::assertToolError(LibraryFixtures::loader($hidden)->load(1058, false), 'not_found'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 4a51280..11f9619 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use stdClass; @@ -138,4 +139,32 @@ final class DiviReadAbilitiesTest extends TestCase self::assertFalse($global['editable']); self::assertNull($plain['global']); } + + public function testOutlineOfALibraryItem(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1059]); + + self::assertSame(['m0'], array_column($result['nodes'], 'id')); + self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']); + self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); + } + + public function testNodeGetOfALibraryItemAndRefusalsWithoutTheLibraryRight(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]); + $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); + + $result = $ability->execute(['post_id' => 1057, 'id' => 'm0']); + self::assertSame('m0', $result['id']); + + $denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); + $refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']); + self::assertToolError($refusal, 'forbidden'); + self::assertStringNotContainsString('Geheim', $refusal->get_error_message()); + $outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); + self::assertToolError($outline->execute(['post_id' => 1057]), 'forbidden'); + } } From 681bb755ff85b985182d5275387538c378710a3f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:07:53 +0200 Subject: [PATCH 13/47] fix: library entries read as not found without the library right (P12) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Library/LibraryAccess.php | 6 ++-- .../Integrations/Divi/DiviIntegrationTest.php | 15 +++++--- .../Divi/Library/LibraryAccessTest.php | 34 +++++++++++++++++-- .../Divi/Pages/DiviPageLoaderTest.php | 2 +- .../Divi/Pages/DiviReadAbilitiesTest.php | 6 ++-- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index fba31c5..0a4d656 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -51,7 +51,9 @@ class LibraryAccess /** * The library entry $postId after the checks for reading, or their refusal; null when $postId * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling - * P9), an entry is refused exactly like an ID that does not exist – before any rights check. + * P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID + * that does not exist, so its type and existence stay unknown; only a user with the library + * right who lacks edit_post on the entry gets forbidden (like pages). */ public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null { @@ -61,7 +63,7 @@ class LibraryAccess return null; } - if ($this->visible !== null && ! ($this->visible)()) { + if (($this->visible !== null && ! ($this->visible)()) || $this->canList() !== null) { return PostGuard::notFound((int) $post->ID); } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 389e5b0..1987b26 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -66,10 +66,17 @@ final class DiviIntegrationTest extends TestCase $schema->allows('isManaged')->andReturn(false); $outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]); - // No Divi in unit tests: DiviRoles allows nothing, so a reachable entry is refused, not missing. - $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static fn (): bool => true); - self::assertToolError($outline($visible), 'forbidden'); - self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'forbidden'); + // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12); + // the wiring shows in which gate the integration asks. + $asked = 0; + $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool { + ++$asked; + + return true; + }); + self::assertToolError($outline($visible), 'not_found'); + self::assertSame(1, $asked); + self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found'); $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false); $refusal = $outline($hidden); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index e76d012..5f50357 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -5,8 +5,12 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use WP_Error; use WP_Post; final class LibraryAccessTest extends TestCase @@ -24,15 +28,18 @@ final class LibraryAccessTest extends TestCase self::assertNull($access->canList()); } + /** Ruling P12: without the library right at all, library IDs read like missing IDs (no type oracle). */ public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void { $item = LibraryFixtures::item(1055); LibraryFixtures::given([$item], ['edit_posts']); - self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden'); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access()->canList(), 'forbidden'); LibraryFixtures::given([$item]); - self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canList(), 'forbidden'); LibraryFixtures::given([$item], ['edit_post']); $refusal = LibraryFixtures::access()->item(1055, false); @@ -63,7 +70,7 @@ final class LibraryAccessTest extends TestCase foreach ([['edit_posts'], ['edit_post']] as $denied) { LibraryFixtures::given([$item], $denied); $refusal = LibraryFixtures::access()->item(1055, false); - self::assertToolError($refusal, 'forbidden'); + self::assertToolError($refusal, $denied === ['edit_post'] ? 'forbidden' : 'not_found'); self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); self::assertStringNotContainsString('Geheimer Entwurf', (string) wp_json_encode($refusal->get_error_data())); } @@ -85,4 +92,25 @@ final class LibraryAccessTest extends TestCase self::assertNull($hidden->item(LibraryFixtures::PAGE, false), 'pages stay PostGuard\'s business'); self::assertNull($hidden->item(9999, false)); } + + /** Ruling P12: the refusal without the library right is byte-identical to a missing ID's. */ + public function testWithoutTheLibraryRightAnEntryIsIndistinguishableFromAMissingId(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + + foreach ([[['edit_posts'], []], [[], ['divi_library']]] as [$denied, $deniedRoles]) { + LibraryFixtures::given([LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf'])], $denied); + $refusal = LibraryFixtures::access($deniedRoles)->item(1055, false); + + LibraryFixtures::given([], $denied); + $missing = PostGuard::editable(1055, $schema, 'post_id', true); + + self::assertInstanceOf(WP_Error::class, $refusal); + self::assertInstanceOf(WP_Error::class, $missing); + self::assertSame($missing->get_error_code(), $refusal->get_error_code()); + self::assertSame($missing->get_error_message(), $refusal->get_error_message()); + self::assertSame($missing->get_error_data(), $refusal->get_error_data()); + } + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 3e9824f..6092aee 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -123,7 +123,7 @@ final class DiviPageLoaderTest extends TestCase self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); self::assertToolError( (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), - 'forbidden' + 'not_found' ); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 11f9619..e934793 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -152,7 +152,7 @@ final class DiviReadAbilitiesTest extends TestCase self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); } - public function testNodeGetOfALibraryItemAndRefusalsWithoutTheLibraryRight(): void + public function testNodeGetOfALibraryItemAndNotFoundWithoutTheLibraryRight(): void { LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]); $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); @@ -162,9 +162,9 @@ final class DiviReadAbilitiesTest extends TestCase $denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); $refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']); - self::assertToolError($refusal, 'forbidden'); + self::assertToolError($refusal, 'not_found'); self::assertStringNotContainsString('Geheim', $refusal->get_error_message()); $outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); - self::assertToolError($outline->execute(['post_id' => 1057]), 'forbidden'); + self::assertToolError($outline->execute(['post_id' => 1057]), 'not_found'); } } From d66ad36c2eae9827aba5681785b1a7f3b7b5be5b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:15:58 +0200 Subject: [PATCH 14/47] feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/ExposurePolicy.php | 6 + src/Integrations/Divi/DiviIntegration.php | 11 +- src/Integrations/Divi/Library/GlobalEdits.php | 200 ++++++++++ .../Divi/Library/GlobalUsages.php | 88 +++++ .../Divi/Library/LibraryAccess.php | 45 ++- .../Divi/Pages/DiviDeleteAbility.php | 1 - .../Divi/Pages/DiviPageLoader.php | 11 +- .../Divi/Pages/DiviReplaceTextAbility.php | 37 +- src/Integrations/Divi/Pages/DiviSaver.php | 41 ++- src/Integrations/Divi/Pages/DiviSchema.php | 5 + .../Divi/Pages/DiviWriteAbility.php | 38 +- src/Integrations/Divi/Pages/DiviWriter.php | 58 ++- src/Integrations/Divi/Pages/SaveScope.php | 33 ++ src/Plugin.php | 6 +- tests/Stubs/EtCorePageResource.php | 19 + tests/Support/DiviWriterFixture.php | 7 +- tests/Unit/Abilities/ExposurePolicyTest.php | 8 + .../Divi/Library/GlobalEditsTest.php | 345 ++++++++++++++++++ .../Divi/Library/GlobalUsagesTest.php | 65 ++++ .../Divi/Library/LibraryAccessTest.php | 29 ++ .../Divi/Pages/DiviPageLoaderTest.php | 23 +- .../Integrations/Divi/Pages/DiviSaverTest.php | 33 ++ tests/bootstrap.php | 1 + 23 files changed, 1066 insertions(+), 44 deletions(-) create mode 100644 src/Integrations/Divi/Library/GlobalEdits.php create mode 100644 src/Integrations/Divi/Library/GlobalUsages.php create mode 100644 src/Integrations/Divi/Pages/SaveScope.php create mode 100644 tests/Stubs/EtCorePageResource.php create mode 100644 tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index a5c8c3c..f323ab5 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -181,6 +181,12 @@ final class ExposurePolicy return isset($this->readonlyGroups[$group]); } + /** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */ + public function groupWritable(string $group): bool + { + return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED; + } + /** * Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The * group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 80508a7..5539338 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -11,6 +11,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; @@ -98,7 +100,14 @@ final class DiviIntegration implements Integration { $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); $composition = new DiviComposition($this->modules, $this->design); - $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); + $writer = new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + $composition, + new GlobalEdits($this->access, new GlobalUsages()) + ); return [ new DiviOutlineAbility($loader, $this->design), diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php new file mode 100644 index 0000000..5daa105 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -0,0 +1,200 @@ +backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item); + $this->trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + /** + * @param array $input + * @return array{scope: SaveScope, details: array}|WP_Error + * @throws WriteFailed When the backup could not be made (nothing was saved). + */ + public function prepare(DiviPage $page, array $input): array|WP_Error + { + $item = $page->post; + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + try { + $before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + $before = []; + } + + $violation = LibraryContent::shapeViolation( + $before, + LibraryContent::roots($page->document), + $this->access->store()->layoutType($id), + $global + ); + if ($violation !== null) { + return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]); + } + + if (! $global) { + return ['scope' => SaveScope::LibraryItem, 'details' => []]; + } + + $usages = $this->usages->of($id); + $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']]; + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + sprintf( + 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' + . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', + $id, + $usages['count'], + $usages['count'] === 1 ? 'Seite' : 'Seiten' + ), + $details + ); + } + + $refusal = $this->pagesRefusal($id) ?? $this->backupRefusal($item); + if ($refusal !== null) { + return $refusal; + } + + if (! wp_revisions_enabled($item)) { + $details['backup_id'] = ($this->backup)($item); + } + + return ['scope' => SaveScope::GlobalItem, 'details' => $details]; + } + + /** + * A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected + * pages – so the one confirm the client sends covers both questions. + */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) { + return $error; + } + + $usages = $this->usages->of((int) $page->post->ID); + $data = $error->get_error_data(); + $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; + + return Errors::confirmRequired( + Errors::clientMessage($error) + . sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']), + array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']]) + ); + } + + /** Ruling P8: every published page that shows entry $id must be editable by the user. */ + private function pagesRefusal(int $id): ?WP_Error + { + $blocked = array_values(array_filter( + $this->usages->publishedIds($id), + static fn (int $pageId): bool => ! current_user_can('edit_post', $pageId) + )); + if ($blocked === []) { + return null; + } + + $listed = implode(', ', array_slice($blocked, 0, self::LISTED_IDS)); + $more = count($blocked) - self::LISTED_IDS; + + return Errors::forbidden( + sprintf( + 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten Einträgen, die dieses Konto nicht ' + . 'bearbeiten darf (IDs %s%s); nichts wurde gespeichert.', + $id, + $listed, + $more > 0 ? sprintf(' und %d weitere', $more) : '' + ), + 'Die Änderung von jemandem vornehmen lassen, der alle diese Seiten bearbeiten darf (z. B. Redakteur ' + . 'oder Administrator).' + ); + } + + /** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */ + private function backupRefusal(WP_Post $item): ?WP_Error + { + if (wp_revisions_enabled($item)) { + return null; + } + + $hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder ' + . 'bearbeiten.'; + if (! current_user_can('unfiltered_html')) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses ' + . 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + sprintf( + 'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert ' + . '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde ' + . 'gespeichert.', + (int) $item->ID + ), + $hint + ); + } + + return null; + } +} diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php new file mode 100644 index 0000000..810fbe8 --- /dev/null +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -0,0 +1,88 @@ +" – Divi's own way to find instances (string scan, + * BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it. + */ +class GlobalUsages +{ + public const LIMIT = 50; + + private const WHERE = "post_content LIKE %s AND post_type <> 'revision' " + . "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')"; + + public function __construct(private ?\wpdb $db = null) + { + } + + /** @return array{items: list, count: int} */ + public function of(int $libraryId): array + { + $db = $this->db(); + $like = self::like($db, $libraryId); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $count = (int) $db->get_var( + $db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like) + ); + $rows = $db->get_results( + $db->prepare( + 'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE + . ' ORDER BY ID ASC LIMIT %d', + $like, + self::LIMIT + ), + 'ARRAY_A' + ); + // phpcs:enable + + $items = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $id = (int) $row['ID']; + $items[] = [ + 'post_id' => $id, + 'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)', + 'status' => (string) $row['post_status'], + 'type' => (string) $row['post_type'], + ]; + } + + return ['items' => $items, 'count' => max($count, count($items))]; + } + + /** + * IDs of every published post that shows $libraryId – no limit (ruling P8 checks edit_post on + * each of them before a confirmed global edit). + * + * @return list + */ + public function publishedIds(int $libraryId): array + { + $db = $this->db(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $ids = $db->get_col($db->prepare( + 'SELECT ID FROM ' . $db->posts . " WHERE post_content LIKE %s AND post_type <> 'revision' " + . "AND post_status = 'publish' ORDER BY ID ASC", + self::like($db, $libraryId) + )); + // phpcs:enable + + return array_values(array_map('intval', is_array($ids) ? $ids : [])); + } + + private function db(): \wpdb + { + return $this->db ?? $GLOBALS['wpdb']; + } + + private static function like(\wpdb $db, int $libraryId): string + { + return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; + } +} diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index 0a4d656..f097fed 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -53,7 +53,7 @@ class LibraryAccess * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling * P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID * that does not exist, so its type and existence stay unknown; only a user with the library - * right who lacks edit_post on the entry gets forbidden (like pages). + * right who lacks edit_post on the entry gets forbidden (like pages). $forWrite adds canEdit(). */ public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null { @@ -67,7 +67,7 @@ class LibraryAccess return PostGuard::notFound((int) $post->ID); } - return $this->canRead($post) ?? $post; + return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post; } /** Listing the library: edit_posts and the Divi role option divi_library. */ @@ -93,6 +93,47 @@ class LibraryAccess : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); } + /** + * Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live + * entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route + * does not check edit_post on the entry, F9; LIT MCP does). + */ + public function canEdit(WP_Post $item): ?WP_Error + { + $refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate(); + if ($refusal !== null) { + return $refusal; + } + + if ((string) $item->post_status !== 'publish') { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.', + (int) $item->ID, + (string) $item->post_status + ), + ['post_id' => 'nur aktive Bibliothekseinträge'], + 'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.' + ); + } + + return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null; + } + + /** Refusal while the Tools tab keeps this connection from writing to the library. */ + public function writableGate(): ?WP_Error + { + if ($this->writable === null || ($this->writable)()) { + return null; + } + + return Errors::forbidden( + 'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf ' + . '„nur lesen“ oder deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + public function isGlobal(WP_Post $item): bool { return $this->store->isGlobal((int) $item->ID); diff --git a/src/Integrations/Divi/Pages/DiviDeleteAbility.php b/src/Integrations/Divi/Pages/DiviDeleteAbility.php index 1b2a9be..0d52bea 100644 --- a/src/Integrations/Divi/Pages/DiviDeleteAbility.php +++ b/src/Integrations/Divi/Pages/DiviDeleteAbility.php @@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility 'items' => ['type' => 'string'], 'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).', ], - 'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'], ]), 'required' => ['post_id', 'tree_rev', 'ids'], 'additionalProperties' => false, diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 5bcd09f..5da88a3 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -44,8 +44,8 @@ final class DiviPageLoader /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - // Library entries (E1): only for reading so far, and only with a library access. - $item = $forWrite ? null : $this->library?->item($postId, false); + // Library entries (E1): with a library access, for reading and writing. + $item = $this->library?->item($postId, $forWrite); $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; @@ -93,13 +93,14 @@ final class DiviPageLoader } /** - * Additional checks for writing: the type must use the Divi builder, the content must be a - * Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). + * Additional checks for writing: the type must use the Divi builder (library entries always do), + * the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule + * (spec §2). */ public function writable(DiviPage $page): ?WP_Error { $post = $page->post; - if (! $this->schema->diviEnabled((string) $post->post_type)) { + if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) { return Errors::unsupported( sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), 'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).' diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 88bf14e..7a8ce54 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -29,9 +29,11 @@ defined('ABSPATH') || exit; * * Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is * computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of - * it is written; DiviWriter::save() stores it with its own restore-point revision and change - * log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged - * and reported there, the others go on. Inserted text is cleaned by the field kind through + * it is written; DiviWriter::commit() stores it with its own restore-point revision and change + * log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never + * changed here – the target reports the pages it shows on, answer F2). The batch reports every + * page (spec §8.2, ruling S10): any Throwable of one page is logged and reported there, the + * others go on. Inserted text is cleaned by the field kind through * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched. */ final class DiviReplaceTextAbility implements Ability @@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability $change->record($block, 'updated'); } - $saved = $this->writer->save($this->name(), $page, $change, $input); + // Ruling P1: through commit() like every Divi tool – library entries keep their shape and get + // no Divi flags. $input never carries confirm, so a global entry only answers with its dry + // run (the pages it would change, answer F2) and is never saved from here. + $saved = $this->writer->commit($this->name(), $page, $change, $input); + if ($saved instanceof WP_Error) { + return $base + self::refused($saved); + } return $base + [ 'ok' => true, @@ -378,6 +386,27 @@ final class DiviReplaceTextAbility implements Ability ]; } + /** + * A page commit() refused: its message, and for a global library entry the pages it shows on. + * + * @return array + */ + private static function refused(WP_Error $error): array + { + $data = $error->get_error_data(); + if (Errors::codeOf($error) !== 'confirm_required' || ! is_array($data)) { + return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.']; + } + + return [ + 'ok' => false, + 'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, ' + . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', + 'affected' => $data['affected'] ?? [], + 'affected_count' => $data['affected_count'] ?? 0, + ]; + } + /** * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's * own composition (ruling S22). diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 940af63..ac6d639 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -50,6 +50,9 @@ defined('ABSPATH') || exit; * pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * + * SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache + * aller Seiten und den Theme-Builder-Cache. + * * If saving throws, the previous content is written back (WriteFailed: rolled back or not). * Not final so abilities can be tested with a mock. */ @@ -64,7 +67,7 @@ class DiviSaver * @return array{revision_id: ?int, warnings: list} * @throws WriteFailed */ - public function save(WP_Post $post, string $content): array + public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; $revisionId = null; @@ -90,17 +93,27 @@ class DiviSaver } $warnings = []; + if ($scope->setsFlags()) { + FollowUp::run( + static fn () => DiviFlags::set($postId), + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + } FollowUp::run( - static fn () => DiviFlags::set($postId), - 'divi builder flags', - 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' - . 'einmal im Divi-Builder öffnen und speichern.', - $warnings - ); - FollowUp::run( - static function () use ($postId): void { - if (class_exists('ET_Core_PageResource')) { - \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + static function () use ($postId, $scope): void { + if (! class_exists('ET_Core_PageResource')) { + return; + } + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + // A global library entry is part of every page that shows it (spec §5.6 step 5). + if ($scope->clearsSiteCss()) { + \ET_Core_PageResource::remove_static_resources('all', 'all', true); + if (function_exists('et_theme_builder_clear_wp_cache')) { + et_theme_builder_clear_wp_cache('all'); + } } }, 'divi static css', @@ -128,14 +141,16 @@ class DiviSaver * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException */ - public function saveNew(int $postId, string $content): array + public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); } - DiviFlags::set($postId); + if ($scope->setsFlags()) { + DiviFlags::set($postId); + } $stored = get_post($postId); diff --git a/src/Integrations/Divi/Pages/DiviSchema.php b/src/Integrations/Divi/Pages/DiviSchema.php index 8d4913e..bf6f97c 100644 --- a/src/Integrations/Divi/Pages/DiviSchema.php +++ b/src/Integrations/Divi/Pages/DiviSchema.php @@ -18,6 +18,11 @@ final class DiviSchema 'type' => 'string', 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', ], + 'confirm' => [ + 'type' => 'boolean', + 'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer ' + . 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.', + ], ]; } diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 5a90b13..fac7ac2 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -14,8 +14,8 @@ defined('ABSPATH') || exit; * The frame of the Divi write tools (spec §5.6): permission and execute both load the page * through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state * from permission), the tool changes the tree in change() – only when its whole input - * validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the - * result. The composition is bound to the page (DiviWriter::composition(), ruling S22). + * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries: + * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22). */ abstract class DiviWriteAbility implements Ability { @@ -33,7 +33,14 @@ abstract class DiviWriteAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + return ToolResult::schema([ + 'tree_rev' => ['type' => 'string'], + // Global library entries (answer F2): the pages that show the entry, and the backup + // made when revisions are off. + 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], + 'affected_count' => ['type' => 'integer'], + 'backup_id' => ['type' => 'integer'], + ], ['tree_rev']); } public function isDirect(): bool @@ -43,6 +50,11 @@ abstract class DiviWriteAbility implements Ability final public function permission(array $input): bool|WP_Error { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); return $page instanceof WP_Error ? $page : true; @@ -50,6 +62,11 @@ abstract class DiviWriteAbility implements Ability final public function execute(array $input): mixed { + $refusal = $this->precheck($input); + if ($refusal !== null) { + return $refusal; + } + $page = $this->writer->page($input); if ($page instanceof WP_Error) { return $page; @@ -57,9 +74,20 @@ abstract class DiviWriteAbility implements Ability $change = $this->change($page, $input, $this->writer->composition($page)); if ($change instanceof WP_Error) { - return $change; + return $this->writer->notice($page, $change); } - return $this->writer->save($this->name(), $page, $change, $input); + return $this->writer->commit($this->name(), $page, $change, $input); + } + + /** + * Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in + * permission() and again in execute(). Null = none. + * + * @param array $input + */ + protected function precheck(array $input): ?WP_Error + { + return null; } } diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 42a1e11..5c0fda0 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use WP_Error; @@ -23,7 +24,8 @@ defined('ABSPATH') || exit; * (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, - * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. + * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point + * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup). */ final class DiviWriter { @@ -35,7 +37,8 @@ final class DiviWriter private DiviSaver $saver, private ChangeLog $log, private DesignSystemReader $design, - private DiviComposition $composition + private DiviComposition $composition, + private ?GlobalEdits $globals = null ) { } @@ -79,8 +82,13 @@ final class DiviWriter * @return array * @throws WriteFailed */ - public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array - { + public function save( + string $ability, + DiviPage $page, + DiviChange $change, + array $input, + SaveScope $scope = SaveScope::Page + ): array { $post = $page->post; $content = Serializer::document($page->document); if ($content === (string) $post->post_content) { @@ -97,7 +105,7 @@ final class DiviWriter $warnings = array_merge($change->warnings(), $quality); try { - $saved = $this->saver->save($post, $content); + $saved = $this->saver->save($post, $content, $scope); } catch (WriteFailed $e) { if (! $e->rolledBack) { $this->log->recordPartial( @@ -140,6 +148,46 @@ final class DiviWriter return $tool->toArray(); } + /** + * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one + * needs confirm (dry run first), gets a backup when revisions are off and clears every page's + * cache; the result then names the affected pages. + * + * @param array $input + * @return array|WP_Error + * @throws WriteFailed + */ + public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error + { + if (! $page->isLibraryItem()) { + return $this->save($ability, $page, $change, $input); + } + + if ($this->globals === null) { + return Errors::unsupported( + 'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.', + 'Den Eintrag im Divi-Builder bearbeiten.' + ); + } + + if (Serializer::document($page->document) === (string) $page->post->post_content) { + return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem); + } + + $prepared = $this->globals->prepare($page, $input); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']); + } + + /** A tool's confirm_required on a global library entry, extended by the affected pages. */ + public function notice(DiviPage $page, WP_Error $error): WP_Error + { + return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error; + } + /** * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * node has no block any more, but its ID from before the removal still names the (surviving) diff --git a/src/Integrations/Divi/Pages/SaveScope.php b/src/Integrations/Divi/Pages/SaveScope.php new file mode 100644 index 0000000..8841307 --- /dev/null +++ b/src/Integrations/Divi/Pages/SaveScope.php @@ -0,0 +1,33 @@ +groupEnabled(LibraryAccess::GROUP); }; - $divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible); // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // when registry and policy exist), so the Divi group's switches stop every Divi layout write. $diviWritable = static function () use (&$registry, &$policy): bool { @@ -86,6 +85,11 @@ final class Plugin && $policy instanceof ExposurePolicy && $policy->allows('lit/divi-insert', $registry->abilities()); }; + // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9). + $libraryWritable = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); + }; + $divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), diff --git a/tests/Stubs/EtCorePageResource.php b/tests/Stubs/EtCorePageResource.php new file mode 100644 index 0000000..1b09b7f --- /dev/null +++ b/tests/Stubs/EtCorePageResource.php @@ -0,0 +1,19 @@ + */ + public static array $removed = []; + + public static function remove_static_resources($postId, $owner, $force = false): void + { + self::$removed[] = [$postId, $owner, (bool) $force]; + } + } +} diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php index e37e2ec..07deb20 100644 --- a/tests/Support/DiviWriterFixture.php +++ b/tests/Support/DiviWriterFixture.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; @@ -98,7 +99,8 @@ final class DiviWriterFixture array &$saved, array &$failures = [], bool $unfiltered = false, - ?DiviPageLoader $loader = null + ?DiviPageLoader $loader = null, + ?GlobalEdits $globals = null ): DiviWriter { $design = self::designReader(); @@ -107,7 +109,8 @@ final class DiviWriterFixture self::saver($saved, $failures), self::log(), $design, - self::composition($design, $unfiltered) + self::composition($design, $unfiltered), + $globals ); } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index 5951d2f..3c695e8 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -321,4 +321,12 @@ final class ExposurePolicyTest extends TestCase self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available'); } + + public function testGroupWritable(): void + { + self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library')); + self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library')); + self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library')); + } } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php new file mode 100644 index 0000000..b158ee8 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -0,0 +1,345 @@ + Content and scope handed to the saver. */ + private array $saved = []; + + private int $backups = 0; + + /** @var list Published pages that show the global entry (ruling P8). */ + private array $published = [1053]; + + private bool $trash = true; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_revisions_enabled')->justReturn(true); + } + + private function usages(): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn([ + 'items' => [ + ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], + ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], + ], + 'count' => 2, + ]); + $usages->allows('publishedIds')->andReturnUsing(fn (): array => $this->published); + + return $usages; + } + + /** @param list $deniedRoles */ + private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { + $this->saved[] = [$content, $scope]; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $access = LibraryFixtures::access($deniedRoles, $writable); + $design = DiviWriterFixture::designReader(); + $globals = new GlobalEdits( + $access, + $this->usages(), + function (WP_Post $item): int { + ++$this->backups; + + return 1071; + }, + trashEnabled: fn (): bool => $this->trash + ); + + return new DiviWriter( + LibraryFixtures::loader($access), + $saver, + DiviWriterFixture::log(), + $design, + DiviWriterFixture::composition($design), + $globals + ); + } + + private static function rev(int $id): string + { + return Rev::of((string) LibraryFixtures::item($id)->post_content); + } + + /** @return array */ + private static function relabel(int $id, bool $confirm = false): array + { + return array_merge( + ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]], + $confirm ? ['confirm' => true] : [] + ); + } + + /** A user with every capability but $cap on the posts $ids. */ + private static function denyOn(string $cap, array $ids): void + { + Functions\when('current_user_can')->alias( + static fn (string $asked, mixed ...$args): bool => ! ($asked === $cap + && ($ids === [] || in_array((int) ($args[0] ?? 0), $ids, true))) + ); + } + + public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + + $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + + self::assertTrue($result['ok']); + self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]); + self::assertStringStartsWith(''; + } + + /** @param array $settings */ + private static function ref(string $name, array $settings): string + { + return VariableRef::MARKER . json_encode( + ['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]], + JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ) . VariableRef::END; + } + + /** @return array */ + private static function desktop(mixed $value): array + { + return ['desktop' => ['value' => $value]]; + } + + private static function text(string $html): string + { + return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]); + } + + public function testBuilderContentWithInstancesAndLinksPasses(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + $link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']), + ])]])); + $title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => '']))); + + self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)'); + self::assertFalse($title->isOpaque()); + self::assertSame([], self::guard()->problems($index->find('s0'))); + self::assertSame([], self::guard()->problems($index->find('g0'))); + self::assertSame([], self::guard()->problems($link)); + self::assertSame([], self::guard()->problems($title)); + self::assertSame([], self::guard()->problems(self::block(self::text('

Tom & Jerry

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

x

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

Hallo

' . self::PROBE_HTML), 'content.innerContent'], + 'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'], + 'script in a text field' => [ + self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), + 'title.innerContent', + ], + 'script url' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => 'javascript:void(0)', + ])]]), + 'button.innerContent', + ], + 'control character' => [self::text('

a' . chr(1) . 'b

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

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

'), + 'allein im Wert', + ], + 'content reference as a link' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]), + ])]]), + 'button.innerContent', + ], + ]; + } + + /** @dataProvider refused */ + public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( + string $markup, + string $problem, + bool $opaque = false + ): void { + $block = self::block($markup); + self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)'); + + $problems = self::guard()->problems($block); + + self::assertNotSame([], $problems); + self::assertStringContainsString($problem, implode(' | ', $problems)); + self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it'); + } + + public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void + { + $markup = '' + . self::node('divi/code', []) . self::node('divi/code', []) + . self::text(self::PROBE_SCRIPT) + . ''; + $block = self::block($markup); + + $problems = self::guard()->problems($block); + + self::assertCount(2, $problems); + self::assertSame($markup, Serializer::block($block)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index d4a656f..d54b5b8 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; +use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; @@ -16,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -137,4 +139,21 @@ final class DiviCompositionTest extends TestCase self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); self::assertInstanceOf(TreeEditor::class, $composition->editor()); } + + public function testStoredGuardSharesTheCleanerAndReferences(): void + { + $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); + $code = Block::create('divi/code', ['builderVersion' => '5.2.1']); + + self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); + self::assertSame( + [], + $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) + ); + self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used'); + self::assertSame( + [], + DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code) + ); + } } From 9d92808918c0de59b345d4c3eb718765dc27a913 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:54:25 +0200 Subject: [PATCH 18/47] fix: interactions are builder-only for users without unfiltered_html (P16) Divi's frontend applies the interaction effect "Attribute" with setAttribute (e.g. an onclick handler) and sets cookies; *.decoration.interactions joins the S7 gate in ValueCleaner, which also covers removals and StoredNodeGuard. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-attrs.md | 4 +-- docs/guides/divi-shortform.md | 4 +-- .../Divi/Compose/ValueCleaner.php | 9 +++-- .../Divi/Compose/AttrWriterTest.php | 29 ++++++++++++++++ .../Divi/Compose/ShortformWriterTest.php | 23 +++++++++++++ .../Divi/Compose/StoredNodeGuardTest.php | 11 ++++++ .../Divi/Compose/ValueCleanerTest.php | 34 +++++++++++++++++++ 7 files changed, 107 insertions(+), 7 deletions(-) diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index f07f680..7669eba 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -38,8 +38,8 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt (HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst, -geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute -(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im +geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute und +Interaktionen (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 1c9fb1b..694e5e2 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -106,8 +106,8 @@ Bedarf die Vorlage im Builder neu wählen). Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. -`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`, -`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der +`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute und Interaktionen eines Moduls (`…decoration.attributes`, +`…decoration.interactions`, `…advanced.html`, `…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 142ef8a..0d74b72 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -54,9 +54,12 @@ final class ValueCleaner /** * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, - * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). + * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss), + * and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the + * "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets). */ - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; + private const BUILDER_ONLY + = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html|advanced\.customCss|css)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, @@ -134,7 +137,7 @@ final class ValueCleaner { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( - '"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' + '"%s" (eigenes HTML, CSS, HTML-Attribute oder Interaktionen) ist nur im Divi-Builder änderbar ' . '(oder mit dem Recht unfiltered_html).', $path )); diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 579236f..6553d2b 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -376,4 +376,33 @@ final class AttrWriterTest extends TestCase $heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs'); self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure'); } + + /** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */ + public function testInteractionsNeedUnfilteredHtml(): void + { + $interaction = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $changes = ['module.decoration.interactions' => ['desktop' => $interaction]]; + + $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame(self::TEXT, $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']); + + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']); + + $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame( + 'data-probe', + $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName'] + ); + $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs'); + self::assertSame([], $adminRemoval['errors']); + self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 158a868..f9e074b 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; +use LIT\MCP\Integrations\Divi\Schema\ShortformMap; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -426,4 +427,26 @@ final class ShortformWriterTest extends TestCase return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); } + + /** Ruling P16: no curated short field reaches interactions, and a field that did would be gated. */ + public function testInteractionsAreBuilderOnlyInTheShortForm(): void + { + foreach (ShortformMap::types() as $type) { + foreach (ShortformMap::fields($type) as $name => $field) { + self::assertStringNotContainsString('interactions', $field['path'], $type . '.' . $name); + } + } + + $modules = ['raw' => [ + 'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'], + ]]; + foreach (['data-probe', null] as $value) { + $result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n'); + self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.effects']); + self::assertSame(self::BASE, $result['attrs']); + } + + $admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['effects' => 'x'], 'n'); + self::assertSame([], $admin['errors']); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index 88b12d6..b65a716 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -148,6 +148,17 @@ final class StoredNodeGuardTest extends TestCase ])]]), 'module.advanced.html', ], + 'interactions (P16)' => [ + self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([ + 'interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]], + ])]]]), + 'module.decoration.interactions', + ], // Code module (E6). 'code module' => [ self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 706e1fd..ab21058 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -198,4 +198,38 @@ final class ValueCleanerTest extends TestCase self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test')); self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x')); } + + /** + * Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie, + * Preset) are builder-only like HTML attributes, at every depth. + */ + public function testInteractionPathsNeedUnfilteredHtml(): void + { + $value = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $paths = [ + 'module.decoration.interactions', + 'module.decoration.interactions.desktop', + 'title.decoration.interactions', + 'decoration.interactions', + ]; + foreach ($paths as $path) { + try { + ComposeFixture::cleaner()->clean('divi/text', $path, $value); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value)); + } + + // Only a whole "interactions" segment below "decoration" counts. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x')); + } } From e808832fd3d3b2e6423cf21c03cc13d5b2aa0ae1 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:56:47 +0200 Subject: [PATCH 19/47] fix: StoredNodeGuard checks URLs, value keys and library ids strictly (P17) URLs must be sanitizer-stable, keys inside values follow ValueCleaner::VALUE_KEY, instances need a digits-only globalModule; state keys reuse AttrWriter::STATES. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 3 +- .../Divi/Compose/StoredNodeGuard.php | 25 ++++++++++----- .../Divi/Compose/ValueCleaner.php | 4 +-- .../Divi/Compose/AttrWriterTest.php | 16 +++++----- .../Divi/Compose/ShortformWriterTest.php | 7 +++- .../Divi/Compose/StoredNodeGuardTest.php | 32 ++++++++++++++++--- 6 files changed, 63 insertions(+), 24 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 1978adc..613cd6e 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -23,7 +23,8 @@ defined('ABSPATH') || exit; */ final class AttrWriter { - private const STATES = ['value', 'hover', 'sticky']; + /** State keys of a breakpoint entry (StoredNodeGuard uses them too). */ + public const STATES = ['value', 'hover', 'sticky']; /** * Structure attributes (ruling S41), refused for everyone and whatever the catalog says: diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php index 0068a01..1700748 100644 --- a/src/Integrations/Divi/Compose/StoredNodeGuard.php +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -22,13 +22,15 @@ defined('ABSPATH') || exit; * - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML, * Divi 4 shortcodes), * - an opaque block other than a readable divi/global-layout instance (undecodable attributes, - * a globalModule reference on a plain block, an instance without a usable blockName/localAttrs), + * a globalModule reference on a plain block, an instance without a digits-only globalModule or + * a usable blockName/localAttrs), * - a code module, * - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26), * also when the builder-only key sits below the state value, * - a string with control characters (S32), rich text or code that the sanitizer would change - * (not kses-stable), a plain-text string with markup that is not kses-stable, or a URL the - * sanitizer refuses – routed exactly like ValueCleaner (kindAt()), + * (not kses-stable), a plain-text string with markup that is not kses-stable, a URL the + * sanitizer would change, or a key inside a value that is no plain identifier (S13) – routed + * exactly like ValueCleaner (kindAt(), VALUE_KEY), * - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose * settings canonical() would clean (e.g. before/after with markup such a user may not write). * Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP. @@ -37,8 +39,8 @@ final class StoredNodeGuard { public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; - /** Keys of a breakpoint map entry that are states, no sub-field names. */ - private const STATE_KEYS = ['value', 'hover', 'sticky']; + /** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */ + private const LIBRARY_ID = '/^[1-9][0-9]*$/D'; private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)'; @@ -119,6 +121,7 @@ final class StoredNodeGuard || ! str_starts_with($name, 'divi/') || $name === GlobalInstance::BLOCK || ! is_string($attrs['globalModule'] ?? null) + || preg_match(self::LIBRARY_ID, $attrs['globalModule']) !== 1 ) { return null; } @@ -158,7 +161,7 @@ final class StoredNodeGuard continue; } foreach ($states as $state => $item) { - $field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state; + $field = in_array($state, AttrWriter::STATES, true) ? null : (string) $state; $where = $field === null ? $path : $path . '.' . $field; $this->node($module, $path, $where, $item, $field, $field, $problems); } @@ -190,7 +193,13 @@ final class StoredNodeGuard if (is_array($value)) { foreach ($value as $childKey => $item) { - if (is_string($childKey)) { + if (is_string($childKey) && preg_match(ValueCleaner::VALUE_KEY, $childKey) !== 1) { + $problems[] = sprintf( + '%s: ungültiger Schlüssel im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-") – %s.', + $where, + self::BUILDER_ONLY + ); + } elseif (is_string($childKey)) { $below = $where . '.' . $childKey; $this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems); } else { @@ -238,7 +247,7 @@ final class StoredNodeGuard ValueKind::Html => $this->sanitizer->html($value) === $value, ValueKind::Code => $this->sanitizer->code($value) === $value, ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value, - ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '', + ValueKind::Url => $this->sanitizer->url($value) === trim($value), }; return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 0d74b72..d828329 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -45,8 +45,8 @@ final class ValueCleaner /** Keys whose string values are URLs when the catalog names no kind (ruling S3). */ private const URL_KEY = '/(url|src|link|href|image)$/i'; - /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ - private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; + /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */ + public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ private const URL_SEGMENT = '/url$/i'; diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 6553d2b..9c5105d 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -390,18 +390,18 @@ final class AttrWriterTest extends TestCase $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame(self::TEXT, $editor['attrs']); - self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']); + $key = 'attrs.module.decoration.interactions'; + self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); - $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs'); - self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']); + $remove = ['module.decoration.interactions' => null]; + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $remove, 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame([], $admin['errors']); - self::assertSame( - 'data-probe', - $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName'] - ); - $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs'); + $stored = $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions']; + self::assertSame('data-probe', $stored[0]['attributeName']); + $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], $remove, 'attrs'); self::assertSame([], $adminRemoval['errors']); self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index f9e074b..18f09ef 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -438,7 +438,12 @@ final class ShortformWriterTest extends TestCase } $modules = ['raw' => [ - 'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'], + 'effects' => [ + 'path' => 'module.decoration.interactions', + 'sub' => 'interactions', + 'kind' => 'text', + 'description' => 'x', + ], ]]; foreach (['data-probe', null] as $value) { $result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n'); diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index b65a716..3951f5f 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -34,7 +34,9 @@ final class StoredNodeGuardTest extends TestCase ); $sanitizer = ComposeFixture::sanitizer($unfiltered); - return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer); + $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); + + return new StoredNodeGuard($cleaner, $references, $sanitizer); } private static function block(string $markup): Block @@ -88,7 +90,8 @@ final class StoredNodeGuardTest extends TestCase self::assertSame([], self::guard()->problems($index->find('g0'))); self::assertSame([], self::guard()->problems($link)); self::assertSame([], self::guard()->problems($title)); - self::assertSame([], self::guard()->problems(self::block(self::text('

Tom & Jerry

')))); + $rich = self::block(self::text('

Tom & Jerry

')); + self::assertSame([], self::guard()->problems($rich)); } /** @return array */ @@ -117,7 +120,8 @@ final class StoredNodeGuardTest extends TestCase /** @return array Markup, part of the problem, parsed opaque. */ public static function refused(): array { - $section = static fn (string $inner): string => '' + $section = static fn (string $inner): string => '' + . '' . $inner . ''; return [ @@ -189,7 +193,10 @@ final class StoredNodeGuardTest extends TestCase true, ], 'instance with local attributes that are no object' => [ - self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']), + self::node( + 'divi/global-layout', + ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x'] + ), 'nicht lesbar', true, ], @@ -237,6 +244,23 @@ final class StoredNodeGuardTest extends TestCase ])]]), 'button.innerContent', ], + 'url the sanitizer would change' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'linkUrl' => 'https://kunde.test/"data-probe="1', + ])]]), + 'button.innerContent.linkUrl', + ], + 'odd key inside a value' => [ + self::node('divi/text', ['module' => ['decoration' => ['spacing' => self::desktop([ + '' => '1px', + ])]]]), + 'Schlüssel', + ], + 'instance with a non-numeric library id' => [ + self::node('divi/global-layout', ['globalModule' => '5x', 'blockName' => 'divi/text']), + 'nicht lesbar', + true, + ], 'control character' => [self::text('

a' . chr(1) . 'b

'), 'Steuerzeichen'], // Stored $variable references: the same check as client references (VariableRef). 'script in before' => [ From 16c6d59725d332a015def643c6664ae75e4cd501 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:58:47 +0200 Subject: [PATCH 20/47] fix: StoredNodeGuard keeps plain relative links copyable (P18) A link without scheme and without whitespace, quotes, <, >, backslashes or control characters passes although esc_url_raw() would prefix http://; every value with a scheme, and protocol-relative //host, must survive url() unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Compose/StoredNodeGuard.php | 21 +++- .../Divi/Compose/StoredNodeGuardTest.php | 105 ++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php index 1700748..0909f11 100644 --- a/src/Integrations/Divi/Compose/StoredNodeGuard.php +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -29,8 +29,8 @@ defined('ABSPATH') || exit; * also when the builder-only key sits below the state value, * - a string with control characters (S32), rich text or code that the sanitizer would change * (not kses-stable), a plain-text string with markup that is not kses-stable, a URL the - * sanitizer would change, or a key inside a value that is no plain identifier (S13) – routed - * exactly like ValueCleaner (kindAt(), VALUE_KEY), + * sanitizer would change (plain relative links excepted, ruling P18), or a key inside a value + * that is no plain identifier (S13) – routed exactly like ValueCleaner (kindAt(), VALUE_KEY), * - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose * settings canonical() would clean (e.g. before/after with markup such a user may not write). * Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP. @@ -42,6 +42,9 @@ final class StoredNodeGuard /** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */ private const LIBRARY_ID = '/^[1-9][0-9]*$/D'; + /** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, backslash or controls. */ + private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D'; + private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)'; public function __construct( @@ -247,13 +250,25 @@ final class StoredNodeGuard ValueKind::Html => $this->sanitizer->html($value) === $value, ValueKind::Code => $this->sanitizer->code($value) === $value, ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value, - ValueKind::Url => $this->sanitizer->url($value) === trim($value), + ValueKind::Url => $this->sanitizer->url($value) === trim($value) || self::isPlainRelativeUrl($value), }; return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' . self::BUILDER_ONLY . '.'; } + /** + * Ruling P18: a link without scheme – nothing before the first "/", "?" or "#" contains ":" – and + * without whitespace, quotes, "<", ">", backslashes or control characters ("kontakt/", "/pfad", + * "#anker", "?q=1"). esc_url_raw() would prefix "http://" to some of them, yet they cannot carry + * javascript: or data:. Protocol-relative "//host" (and "\\host", which browsers read the same) + * does not count: it must survive url() unchanged. + */ + private static function isPlainRelativeUrl(string $value): bool + { + return preg_match(self::RELATIVE_URL, $value) === 1 && ! str_starts_with($value, '//'); + } + /** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */ private function referenceProblem(string $value, bool $url): ?string { diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index 3951f5f..c3867d2 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; +use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Tests\Support\ComposeFixture; @@ -322,4 +323,108 @@ final class StoredNodeGuardTest extends TestCase self::assertCount(2, $problems); self::assertSame($markup, Serializer::block($block)); } + + /** + * Like esc_url_raw() for an editor: only allowed protocols, characters outside its list go, and a + * value without scheme that does not start with "/", "#" or "?" gets "http://" (so url() changes + * relative links like "kontakt/"). $keepProtocolRelative decides what "//host" becomes. + */ + private static function wpLikeGuard(bool $keepProtocolRelative = true): StoredNodeGuard + { + $inner = ComposeFixture::sanitizer(); + $sanitizer = new class ($inner, $keepProtocolRelative) implements ValueSanitizer { + public function __construct(private ValueSanitizer $inner, private bool $keepProtocolRelative) + { + } + + public function text(string $value): string + { + return $this->inner->text($value); + } + + public function html(string $value): string + { + return $this->inner->html($value); + } + + public function code(string $value): string + { + return $this->inner->code($value); + } + + public function url(string $value): string + { + $allowed = '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i'; + $value = (string) preg_replace($allowed, '', trim($value)); + if (str_starts_with($value, '//')) { + return $this->keepProtocolRelative ? $value : 'http:' . $value; + } + if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $value, $scheme) === 1) { + return in_array(strtolower($scheme[1]), ['http', 'https', 'mailto', 'tel'], true) ? $value : ''; + } + + return preg_match('/^[\/#?]/', $value) === 1 ? $value : 'http://' . $value; + } + + public function allowsUnfilteredHtml(): bool + { + return false; + } + + public function canReadPost(int $id): bool + { + return $this->inner->canReadPost($id); + } + }; + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + + $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); + + return new StoredNodeGuard($cleaner, $references, $sanitizer); + } + + private static function link(string $url): Block + { + return self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => $url, + ])]])); + } + + /** Ruling P18: relative links stay copyable although esc_url_raw() would change them. */ + public function testRelativeLinksWithoutSchemePass(): void + { + $accepted = ['kontakt/', '/pfad', '#anker', '?q=1', 'https://kunde.test/a?b=1#c', 'mailto:info@kunde.test']; + foreach ($accepted as $url) { + self::assertSame([], self::wpLikeGuard()->problems(self::link($url)), $url); + } + } + + /** Ruling P18: every value with a scheme must survive url() unchanged; odd relative values are refused. */ + public function testLinksWithASchemeOrOddCharactersAreRefused(): void + { + $refused = [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'java' . chr(9) . 'script:alert(1)', + 'data:text/html,probe', + 'kontakt/"data-probe="1', + "kontakt/'x", + 'kontakt/ x', + 'kontakt/', + '\\evil.example', + '/\\evil.example', + ]; + foreach ($refused as $url) { + self::assertFalse(self::link($url)->isOpaque(), $url); + self::assertNotSame([], self::wpLikeGuard()->problems(self::link($url)), $url); + } + + // Protocol-relative: only when url() leaves it as it is. + self::assertSame([], self::wpLikeGuard()->problems(self::link('//evil.example'))); + self::assertNotSame([], self::wpLikeGuard(false)->problems(self::link('//evil.example'))); + } } From 8fa285a2652ed15f4d28e923fa9658c50ecedb7e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 02:06:38 +0200 Subject: [PATCH 21/47] fix: the Lottie source is builder-only for users without unfiltered_html (P19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lottie.innerContent (src → data-lottie-src) makes the frontend load an animation whose expressions lottie-web runs with eval; it joins the S7 gate in ValueCleaner, which covers removals and StoredNodeGuard. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-attrs.md | 5 +- docs/guides/divi-shortform.md | 4 +- .../Divi/Compose/ValueCleaner.php | 12 +++-- .../Divi/Compose/AttrWriterTest.php | 50 +++++++++++++++++++ .../Divi/Compose/StoredNodeGuardTest.php | 7 +++ .../Divi/Compose/ValueCleanerTest.php | 21 ++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index 7669eba..ffe0f85 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -38,8 +38,9 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt (HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst, -geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute und -Interaktionen (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im +geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute, +Interaktionen und die Lottie-Quelle (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`, +`lottie.innerContent`) nur mit dem Recht `unfiltered_html`, sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 694e5e2..fabee59 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -106,8 +106,8 @@ Bedarf die Vorlage im Builder neu wählen). Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. -`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute und Interaktionen eines Moduls (`…decoration.attributes`, -`…decoration.interactions`, `…advanced.html`, `…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der +`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute, Interaktionen und die Lottie-Quelle eines Moduls (`…decoration.attributes`, +`…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index d828329..531d3f1 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -56,10 +56,12 @@ final class ValueCleaner * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss), * and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the - * "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets). + * "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets), + * and the Lottie source (ruling P19: lottie.innerContent – src becomes data-lottie-src, and the + * lottie-web build runs animation expressions with eval). */ - private const BUILDER_ONLY - = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html|advanced\.customCss|css)(\.|$)/'; + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html' + . '|advanced\.customCss|css|lottie\.innerContent)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, @@ -137,8 +139,8 @@ final class ValueCleaner { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( - '"%s" (eigenes HTML, CSS, HTML-Attribute oder Interaktionen) ist nur im Divi-Builder änderbar ' - . '(oder mit dem Recht unfiltered_html).', + '"%s" (eigenes HTML, CSS, HTML-Attribute, Interaktionen oder Lottie-Quelle) ist nur im ' + . 'Divi-Builder änderbar (oder mit dem Recht unfiltered_html).', $path )); } diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 9c5105d..a8a5adf 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; +use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -405,4 +407,52 @@ final class AttrWriterTest extends TestCase self::assertSame([], $adminRemoval['errors']); self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); } + + /** + * The Lottie module of Divi 5.2.1 (excerpt of lottie/module.json: the animation source group) – + * the sample metadata has no Lottie module. + */ + private static function lottieWriter(bool $unfiltered): AttrWriter + { + $catalog = new ModuleCatalog([[ + 'name' => 'divi/lottie', + 'title' => 'Lottie', + 'category' => 'module', + 'attributes' => ['lottie' => ['type' => 'object', 'settings' => ['innerContent' => [ + 'groupType' => 'group-item', + 'item' => [ + 'attrName' => 'lottie.innerContent', + 'component' => ['type' => 'field', 'name' => 'divi/lottie'], + ], + ]]]], + ]]); + $cleaner = new ValueCleaner($catalog, ComposeFixture::references(), ComposeFixture::sanitizer($unfiltered)); + + return new AttrWriter($catalog, ComposeFixture::placeholders(), $cleaner); + } + + /** Ruling P19: the Lottie source only with unfiltered_html – set and removal. */ + public function testLottieSourceNeedsUnfilteredHtml(): void + { + $changes = ['lottie.innerContent' => ['desktop' => ['src' => 'https://probe.example/anim.json']]]; + $remove = ['lottie.innerContent' => null]; + $key = 'attrs.lottie.innerContent'; + + $editor = self::lottieWriter(false)->apply('divi/lottie', [], $changes, 'attrs'); + self::assertSame([], $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); + + $removal = self::lottieWriter(false)->apply('divi/lottie', [], $remove, 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); + + $admin = self::lottieWriter(true)->apply('divi/lottie', [], $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame( + 'https://probe.example/anim.json', + $admin['attrs']['lottie']['innerContent']['desktop']['value']['src'] + ); + $adminRemoval = self::lottieWriter(true)->apply('divi/lottie', $admin['attrs'], $remove, 'attrs'); + self::assertSame([], $adminRemoval['errors']); + self::assertArrayNotHasKey('lottie', $adminRemoval['attrs']); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index c3867d2..237572b 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -164,6 +164,13 @@ final class StoredNodeGuardTest extends TestCase ])]]]), 'module.decoration.interactions', ], + 'lottie source (P19)' => [ + self::node('divi/lottie', ['lottie' => ['innerContent' => self::desktop([ + 'src' => 'https://probe.example/anim.json', + 'trigger' => 'onLoad', + ])]]), + 'lottie.innerContent', + ], // Code module (E6). 'code module' => [ self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index ab21058..7543687 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -232,4 +232,25 @@ final class ValueCleanerTest extends TestCase self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x')); self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x')); } + + /** Ruling P19: the Lottie source (lottie-web runs expressions with eval) is builder-only, at every depth. */ + public function testLottieSourceNeedsUnfilteredHtml(): void + { + $value = ['src' => 'https://probe.example/anim.json', 'loop' => 'on']; + $paths = ['lottie.innerContent', 'lottie.innerContent.desktop', 'lottie.innerContent.desktop.value.src']; + foreach ($paths as $path) { + try { + ComposeFixture::cleaner()->clean('divi/lottie', $path, $value); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/lottie', $path, $value)); + } + + // The size of the animation and look-alike paths stay open. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.advanced.width', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', 'x')); + } } From 5035092805eef66de7332d471c2dca789e3b997b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 02:12:50 +0200 Subject: [PATCH 22/47] feat: lit/library-save creates library entries like Divi (E4) Reuses NewDraft for insert and rollback (P6), requires edit_global_library for scope global (P7), refuses opaque and global roots before wrapping (P11) and checks the node with StoredNodeGuard. The source page is never written. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 34 ++- .../Divi/Library/DiviLibraryStore.php | 70 +++++ .../Divi/Library/LibraryAccess.php | 28 ++ .../Divi/Library/LibraryIntegration.php | 19 +- .../Divi/Library/LibraryItemSpec.php | 148 +++++++++ .../Divi/Library/LibrarySaveAbility.php | 284 ++++++++++++++++++ .../WordPress/Content/NewDraft.php | 17 +- .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../Divi/Library/DiviLibraryStoreTest.php | 130 ++++++++ .../Divi/Library/LibraryAccessTest.php | 24 ++ .../Divi/Library/LibraryItemSpecTest.php | 113 +++++++ .../Divi/Library/LibrarySaveAbilityTest.php | 240 +++++++++++++++ .../WordPress/Content/NewDraftTest.php | 22 ++ 14 files changed, 1112 insertions(+), 21 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryItemSpec.php create mode 100644 src/Integrations/Divi/Library/LibrarySaveAbility.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 6b053c4..ebe14a2 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 4ea0410..221f67a 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -100,15 +100,7 @@ final class DiviIntegration implements Integration public function abilities(): array { $loader = $this->loader(); - $composition = new DiviComposition($this->modules, $this->design); - $writer = new DiviWriter( - $loader, - new DiviSaver(), - $this->log, - $this->design, - $composition, - new GlobalEdits($this->access, new GlobalUsages()) - ); + $writer = $this->writer($loader); return [ new DiviOutlineAbility($loader, $this->design), @@ -127,7 +119,29 @@ final class DiviIntegration implements Integration /** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */ public function library(): LibraryIntegration { - return new LibraryIntegration($this->access, $this->loader(), $this->design); + $loader = $this->loader(); + + return new LibraryIntegration( + $this->access, + $loader, + $this->design, + $this->writer($loader), + new DiviSaver(), + $this->log, + $this->modules + ); + } + + private function writer(DiviPageLoader $loader): DiviWriter + { + return new DiviWriter( + $loader, + new DiviSaver(), + $this->log, + $this->design, + new DiviComposition($this->modules, $this->design), + new GlobalEdits($this->access, new GlobalUsages()) + ); } private function loader(): DiviPageLoader diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index c393d22..19432c9 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -4,6 +4,11 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\WordPress\Content\NewDraft; +use RuntimeException; +use Throwable; +use WP_Error; use WP_Post; use WP_Term; @@ -126,6 +131,50 @@ class DiviLibraryStore ]; } + /** Client text when the terms or meta of a new entry could not be written. */ + public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.'; + + /** + * A published entry with the terms and meta of $spec and empty content (E4; the layout follows + * through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of + * lit/content-create): a refused insert or missing taxonomies create nothing; a failed term or + * meta write deletes the entry again (write_failed), or reports partial_write and logs it + * when that delete fails; a throwing insert hook is a partial write without ID. + * + * @throws WriteFailed + */ + public function create(LibraryItemSpec $spec, NewDraft $draft): int + { + $missing = $this->missingTaxonomies(); + if ($missing !== []) { + throw WriteFailed::rolledBack( + 'Die Divi-Bibliothek ist in dieser Anfrage nicht geladen.', + detail: 'Divi Library taxonomies not registered: ' . implode(', ', $missing) + ); + } + + $id = $draft->insert([ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $spec->title, + 'post_content' => '', + ]); + if ($id instanceof WP_Error) { + throw WriteFailed::rolledBack( + 'WordPress hat den Bibliothekseintrag abgelehnt.', + detail: 'wp_insert_post refused the library entry: ' . $id->get_error_message() + ); + } + + try { + $this->describeNew($id, $spec); + } catch (Throwable $e) { + $draft->discard($id, $e, self::REASON_CREATE); + } + + return $id; + } + /** * Library taxonomies not registered in this request (F2: Divi registers them in * ET_Builder_Library; fixture finding 6: not in every context). @@ -140,6 +189,27 @@ class DiviLibraryStore )); } + /** + * Terms and meta of $spec on the new entry $id, each checked. + * + * @throws RuntimeException + */ + private function describeNew(int $id, LibraryItemSpec $spec): void + { + foreach ($spec->terms() as $taxonomy => $terms) { + $result = wp_set_object_terms($id, $terms, $taxonomy); + if ($result instanceof WP_Error) { + throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message())); + } + } + foreach ($spec->meta() as $key => $value) { + update_post_meta($id, $key, $value); + if ((string) get_post_meta($id, $key, true) !== $value) { + throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id)); + } + } + } + /** @return list */ private function terms(int $id, string $taxonomy): array { diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index f097fed..83932b3 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -120,6 +120,34 @@ class LibraryAccess return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null; } + /** + * Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always + * published, F2), the role option save_library and the Tools-tab gate; a global entry + * additionally edit_global_library (ruling P7 – the same option as for editing one, F12). + */ + public function canSave(bool $global = false): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + $type = get_post_type_object(DiviLibraryStore::POST_TYPE); + $publish = is_object($type) && isset($type->cap->publish_posts) + ? (string) $type->cap->publish_posts + : 'publish_posts'; + if (! current_user_can($publish)) { + return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).'); + } + + $refusal = $this->roles->refusal('save_library') ?? $this->writableGate(); + if ($refusal !== null || ! $global) { + return $refusal; + } + + return $this->roles->refusal('edit_global_library'); + } + /** Refusal while the Tools tab keeps this connection from writing to the library. */ public function writableGate(): ?WP_Error { diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index 88c91d4..34dc133 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; +use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** - * The group "Divi-Bibliothek" (E9): lit/library-list, later lit/library-save and lit-library-insert. + * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, later lit-library-insert. * 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. @@ -22,7 +25,11 @@ final class LibraryIntegration implements Integration public function __construct( private LibraryAccess $access, private DiviPageLoader $loader, - private DesignSystemReader $design + private DesignSystemReader $design, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules ) { } @@ -45,6 +52,14 @@ final class LibraryIntegration implements Integration { return [ new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()), + new LibrarySaveAbility( + $this->access, + $this->loader, + $this->writer, + $this->saver, + $this->log, + $this->modules + ), ]; } } diff --git a/src/Integrations/Divi/Library/LibraryItemSpec.php b/src/Integrations/Divi/Library/LibraryItemSpec.php new file mode 100644 index 0000000..e5fa4c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryItemSpec.php @@ -0,0 +1,148 @@ + $categories layout_category term IDs. + * @param list $tags layout_tag term IDs. + */ + public function __construct( + public readonly string $title, + public readonly string $layoutType, + public readonly bool $global, + public readonly string $moduleWidth, + public readonly ?string $moduleType, + public readonly string $builtFor, + public readonly array $categories = [], + public readonly array $tags = [] + ) { + } + + /** + * The entry that holds $node, or the German reason it cannot be saved: sections, rows and + * modules only (Divi's "add to library"); a global module is already an entry; a global entry + * holds no global module (F10). Opaque roots and global instances are refused before anything + * wraps them (ruling P11), so no entry ever starts with an instance. + */ + public static function forNode( + Block $node, + ModuleCatalog $catalog, + string $title, + bool $global, + string $builtFor + ): self|string { + if ($node->isGlobal()) { + $ref = NodeSummary::globalRef($node); + + return sprintf( + 'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert ' + . 'einfügen statt erneut speichern.', + $ref['id'] ?? '?' + ); + } + if ($node->isOpaque()) { + return sprintf( + 'Die Einstellungen von %s sind nicht lesbar; der Knoten lässt sich nicht in der Bibliothek speichern.', + $node->name() + ); + } + + $name = $node->name(); + if ($name === 'divi/section') { + $type = 'section'; + $width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular'; + } elseif ($name === 'divi/row') { + $type = 'row'; + $width = 'regular'; + } elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) { + $type = 'module'; + $width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular'; + } else { + return sprintf( + '%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).', + $name + ); + } + + if ($global && LibraryContent::containsGlobal($node)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung); als normalen Eintrag speichern.'; + } + + return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor); + } + + /** + * @param list $categories + * @param list $tags + */ + public function withTerms(array $categories, array $tags): self + { + return new self( + $this->title, + $this->layoutType, + $this->global, + $this->moduleWidth, + $this->moduleType, + $this->builtFor, + $categories, + $tags + ); + } + + /** @return array> Taxonomy => slugs (system terms) or term IDs. */ + public function terms(): array + { + $terms = [ + 'scope' => [$this->global ? 'global' : 'non_global'], + 'layout_type' => [$this->layoutType], + 'module_width' => [$this->moduleWidth], + ]; + if ($this->categories !== []) { + $terms['layout_category'] = $this->categories; + } + if ($this->tags !== []) { + $terms['layout_tag'] = $this->tags; + } + + return $terms; + } + + /** @return array */ + public function meta(): array + { + $meta = []; + if ($this->moduleType !== null) { + $meta['_et_pb_module_type'] = $this->moduleType; + } + if ($this->global && $this->layoutType === 'module') { + $meta['_et_pb_excluded_global_options'] = '[]'; + } + $meta['_et_pb_built_for_post_type'] = $this->builtFor; + $meta['_et_pb_template_type'] = $this->layoutType; + + return $meta; + } +} diff --git a/src/Integrations/Divi/Library/LibrarySaveAbility.php b/src/Integrations/Divi/Library/LibrarySaveAbility.php new file mode 100644 index 0000000..5e3e1f0 --- /dev/null +++ b/src/Integrations/Divi/Library/LibrarySaveAbility.php @@ -0,0 +1,284 @@ + 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; + + private const SCOPES = ['normal', 'global']; + + private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.'; + + public function __construct( + private LibraryAccess $access, + private DiviPageLoader $loader, + private DiviWriter $writer, + private DiviSaver $saver, + private ChangeLog $log, + private DiviModules $modules + ) { + } + + public function name(): string + { + return 'lit/library-save'; + } + + public function label(): string + { + return 'In der Divi-Bibliothek speichern'; + } + + public function description(): string + { + return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – ' + . 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die ' + . 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit ' + . 'lit-library-insert (mode "global") auf Seiten einfügen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Seite (oder Eintrag) mit dem Knoten.', + ], + 'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'], + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'scope' => ['type' => 'string', 'enum' => self::SCOPES, 'description' => 'Standard: normal.'], + 'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'id', 'title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + ['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']], + ['library_id', 'tree_rev'] + ); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $refusal = $this->access->canSave(self::isGlobal($input)); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + /** @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6). */ + public function execute(array $input): mixed + { + if (! in_array($input['scope'] ?? 'normal', self::SCOPES, true)) { + return Errors::invalidInput('scope ist „normal“ oder „global“; nichts wurde angelegt.', [ + 'scope' => 'normal oder global', + ]); + } + + $refusal = $this->access->canSave(self::isGlobal($input)); + if ($refusal !== null) { + return $refusal; + } + + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.', + DiviPageLoader::OUTLINE_HINT + ); + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + $node = $page->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $page->post); + } + + $title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : ''); + if ($title === '') { + return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']); + } + + $spec = LibraryItemSpec::forNode( + $node, + $this->modules->catalog(), + $title, + self::isGlobal($input), + $this->builtFor($page) + ); + if (is_string($spec)) { + return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]); + } + + $problems = $this->writer->composition($page)->storedGuard()->problems($node); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'angelegt.', + ['id' => implode('; ', $problems)] + ); + } + + $categories = $this->termIds('layout_category', $input['categories'] ?? []); + $tags = $this->termIds('layout_tag', $input['tags'] ?? []); + foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) { + if (is_string($ids)) { + return Errors::invalidInput( + 'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.', + [$field => $ids] + ); + } + } + + return $this->create($page, $spec->withTerms($categories, $tags), self::content($node), $input); + } + + /** + * @param array $input + * @return array + * @throws WriteFailed + */ + private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array + { + $draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag'); + $libraryId = $this->access->store()->create($spec, $draft); + + $warnings = []; + try { + $warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem); + } catch (Throwable $e) { + $draft->discard($libraryId, $e, self::REASON_LAYOUT); + } + + $followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings)) + ->afterSave($input); + $created = $followUp->reread(); + $kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : ''); + $followUp->log( + DiviLibraryStore::POST_TYPE, + sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID) + ); + + $result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created) + ->with('library_id', $libraryId) + ->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content)) + ->urls(null, (string) get_edit_post_link($libraryId, 'raw')); + if ($spec->global) { + $result->warning( + 'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert ' + . '(mode "global") einfügen.' + ); + } + + return $result->toArray(); + } + + /** The node's stored bytes framed like the builder's "add to library", line breaks unified. */ + private static function content(Block $node): string + { + $copy = $node->copy(); + $copy->unifyLineBreaks(); + + return LibraryContent::wrap($copy); + } + + /** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */ + private function builtFor(DiviPage $page): string + { + if (! $page->isLibraryItem()) { + return (string) $page->post->post_type; + } + + $builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true); + + return $builtFor !== '' ? $builtFor : 'page'; + } + + /** @param array $input */ + private static function isGlobal(array $input): bool + { + return ($input['scope'] ?? 'normal') === 'global'; + } + + /** @return list|string Term IDs of existing terms by name, or the problem. */ + private function termIds(string $taxonomy, mixed $names): array|string + { + $ids = []; + foreach (is_array($names) ? $names : [] as $name) { + $term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false; + if (! $term instanceof WP_Term) { + return sprintf( + '„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).', + is_string($name) ? $name : '?' + ); + } + $ids[] = (int) $term->term_id; + } + + return array_values(array_unique($ids)); + } +} diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php index 317a69a..e00987c 100644 --- a/src/Integrations/WordPress/Content/NewDraft.php +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -22,7 +22,8 @@ defined('ABSPATH') || exit; * returned like any invalid input; partial write if the delete fails. * - discard(): writing after the insert failed – created terms and the draft are deleted; * rolled back, or partial write if that cleanup failed. - * Partial writes are logged with result "partial". + * Partial writes are logged with result "partial". lit/library-save reuses it for a new library + * entry (ruling P6): no PostWriter (no terms of its own to clean up), $noun names the entry. */ final class NewDraft { @@ -30,11 +31,13 @@ final class NewDraft public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.'; public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.'; + /** @param string $noun What the new post is called in change-log summaries. */ public function __construct( - private PostWriter $writer, + private ?PostWriter $writer, private ChangeLog $log, private string $ability, - private string $postType + private string $postType, + private string $noun = 'Entwurf' ) { } @@ -50,7 +53,7 @@ final class NewDraft } catch (Throwable $e) { // A hook threw – the row may or may not exist and its ID is unknown; nothing is // searched or deleted, the client is told to check (lit_partial_write without id). - $this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); + $this->logPartial('', sprintf('Anlegen abgebrochen; %s möglicherweise vorhanden', $this->noun)); throw WriteFailed::partial(self::REASON_INSERT, $e); } if ($postId instanceof WP_Error) { @@ -77,7 +80,7 @@ final class NewDraft } if (! $deleted) { - $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); + $summary = sprintf('%s %d: Anlegen nicht zurückgenommen', $this->noun, $postId); $this->logPartial((string) $postId, $summary); $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; throw WriteFailed::partial( @@ -102,7 +105,7 @@ final class NewDraft public function discard(int $postId, Throwable $cause, string $reason): never { $problems = Rollback::attempt( - fn () => $this->writer->deleteCreatedTerms(), + fn () => $this->writer?->deleteCreatedTerms(), static fn () => self::deleteDraft($postId) ); @@ -111,7 +114,7 @@ final class NewDraft throw WriteFailed::rolledBack($reason, $cause, $detail); } - $summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); + $summary = sprintf('%s %d: Anlegen nur teilweise zurückgenommen', $this->noun, $postId); $this->logPartial((string) $postId, $summary); throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 1d14e80..a239cb9 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi-Bibliothek', $library->label()); self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/library-list'], + ['lit/library-list', 'lit/library-save'], array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) ); foreach ($library->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php index 9304c4d..74aa01e 100644 --- a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -5,7 +5,11 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; +use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec; +use LIT\MCP\Integrations\WordPress\Content\NewDraft; +use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; use WP_Post; use WP_Term; @@ -99,4 +103,130 @@ final class DiviLibraryStoreTest extends TestCase self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies()); } + + private static function draft(?FakeChangeLog $log = null): NewDraft + { + return new NewDraft(null, $log ?? FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag'); + } + + public function testCreatesAPublishedEntryWithTermsAndMeta(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + $inserted = []; + Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int { + $inserted = $data; + + return 2001; + }); + $terms = []; + Functions\when('wp_set_object_terms')->alias( + static function (int $id, array $values, string $taxonomy) use (&$terms): array { + $terms[$taxonomy] = $values; + + return [1]; + } + ); + $meta = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool { + $meta[$key] = $value; + + return true; + }); + // By reference: an arrow function would capture the still empty $meta. + Functions\when('get_post_meta')->alias(static function (int $id, string $key) use (&$meta): string { + return $meta[$key] ?? ''; + }); + $spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page'); + + self::assertSame(2001, (new DiviLibraryStore())->create($spec, self::draft())); + self::assertSame( + ['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''], + $inserted + ); + self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms); + self::assertSame('[]', $meta['_et_pb_excluded_global_options']); + } + + public function testAFailedStepDeletesTheNewEntry(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.')); + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001])); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + /** Ruling P6: a failed rollback is an honest partial write, logged as partial (NewDraft). */ + public function testAFailedRollbackIsAPartialWrite(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(2001); + Functions\when('wp_set_object_terms')->justReturn([1]); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log)); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + } + $log->assertPartials([ + 'ability' => 'lit/library-save', + 'object_type' => 'et_pb_layout', + 'object_id' => '2001', + 'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen', + ]); + } + + /** Ruling P6: a throwing insert hook leaves the entry's existence unknown – partial write without id. */ + public function testAThrowingInsertHookIsAPartialWrite(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->alias(static function (): never { + throw new \RuntimeException('hook kaputt'); + }); + $log = FakeChangeLog::partialsOnly(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft($log)); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertNull($e->postId); + } + self::assertCount(1, $log->partials); + } + + public function testARefusedInsertCreatesNothing(): void + { + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('wp_insert_post')->justReturn(new \WP_Error('db_insert_error', 'Datenbankfehler.')); + Functions\expect('wp_delete_post')->never(); + + try { + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void + { + Functions\when('taxonomy_exists')->justReturn(false); + Functions\expect('wp_insert_post')->never(); + + $this->expectException(WriteFailed::class); + (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'), self::draft()); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index 59fe296..2e425a0 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -142,4 +142,28 @@ final class LibraryAccessTest extends TestCase self::assertInstanceOf(WP_Post::class, LibraryFixtures::access([], false)->item(1057, false)); self::assertNull(LibraryFixtures::access()->writableGate()); } + + public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void + { + LibraryFixtures::given([]); + self::assertNull(LibraryFixtures::access()->canSave()); + + LibraryFixtures::given([], ['publish_posts']); + self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden'); + + LibraryFixtures::given([]); + self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canSave(), 'forbidden'); + } + + /** Ruling P7: a global entry additionally needs Divi's edit_global_library. */ + public function testSavingAGlobalEntryNeedsEditGlobalLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access(['edit_global_library'])->canSave()); + self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden'); + self::assertNull(LibraryFixtures::access()->canSave(true)); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php new file mode 100644 index 0000000..44d2b20 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php @@ -0,0 +1,113 @@ +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))) + ->find($id); + } + + private static function libraryRoot(string $file): Block + { + return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0]; + } + + /** @return array Fixture role => [node source, global]. */ + public static function builderEntries(): array + { + return [ + 'library--library-abschnitt' => ['page:s1', false], + 'library--library-zeile' => ['page:s1.r0', false], + 'library--library-modul' => ['page:s1.r0.c0.m0', false], + 'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true], + 'library-global--globales-modul' => ['library:library-global--globales-modul.html', true], + ]; + } + + /** + * Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry), + * without Divi's broken _et_pb_row_layout ("[object Object]", finding 5). + * + * @dataProvider builderEntries + */ + public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void + { + [$kind, $ref] = explode(':', $source, 2); + $node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref); + $role = (string) $this->dataName(); + $json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true); + + $spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page'); + + self::assertInstanceOf(LibraryItemSpec::class, $spec); + $expectedTerms = []; + foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) { + $expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug'); + } + self::assertEquals($expectedTerms, $spec->terms()); + $expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']); + unset($expectedMeta['_et_pb_row_layout']); + self::assertEquals($expectedMeta, $spec->meta()); + } + + public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void + { + $catalog = DiviFixtures::catalog(); + + self::assertStringContainsString( + 'bereits ein globales Modul (Bibliothekseintrag 1058)', + (string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'nur Abschnitte, Zeilen und Module', + (string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page') + ); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page') + ); + self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page')); + } + + /** Ruling P11: opaque roots and instances without a readable id are refused before wrapping. */ + public function testRefusesOpaqueRootsAndUnreadableInstances(): void + { + $catalog = DiviFixtures::catalog(); + $document = (new Parser())->parse( + '' + . '' + ); + [$opaque, $instance] = $document->children(); + self::assertTrue($opaque->isOpaque()); + + self::assertStringContainsString('nicht lesbar', (string) LibraryItemSpec::forNode($opaque, $catalog, 'T', false, 'page')); + self::assertStringContainsString( + 'globales Modul', + (string) LibraryItemSpec::forNode($instance, $catalog, 'T', false, 'page') + ); + } + + public function testFullwidthAndTerms(): void + { + $section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]); + + $spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page'); + + self::assertSame(['fullwidth'], $spec->terms()['module_width']); + self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']); + self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms()); + } +} diff --git a/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php new file mode 100644 index 0000000..1f35080 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php @@ -0,0 +1,240 @@ + */ + private array $specs = []; + + /** @var list */ + private array $contents = []; + + /** @var list Contents DiviWriter saved (the source page must never be written). */ + private array $saved = []; + + private FakeChangeLog $log; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); + LibraryFixtures::given([$this->page]); + Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture' + ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy]) + : false); + $this->log = new FakeChangeLog(4711); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility + { + $store = LibraryFixtures::store(); + $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { + $this->specs[] = $spec; + + return 2001; + }); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array { + if ($layoutFailure !== null) { + throw $layoutFailure; + } + $this->contents[] = [$id, $content, $scope]; + LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]); + + return []; + }); + $access = LibraryFixtures::access($deniedRoles, $writable, $store); + $failures = []; + + return new LibrarySaveAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)), + $saver, + $this->log, + DiviWriterFixture::modules() + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge( + ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], + $extra + ); + } + + public function testSavesASectionLikeTheBuilder(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Fixture']])); + + self::assertTrue($result['ok']); + self::assertSame(2001, $result['library_id']); + self::assertSame('section', $this->specs[0]->layoutType); + self::assertSame([26], $this->specs[0]->terms()['layout_category']); + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), + $this->contents[0][1] + ); + self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]); + self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']); + self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); + self::assertSame('2001', $this->log->records[0]['object_id']); + } + + public function testSavesAGlobalModule(): void + { + $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global'])); + + self::assertTrue($result['ok']); + self::assertTrue($this->specs[0]->global); + self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']); + self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']); + } + + public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed'); + self::assertSame([], $this->specs); + } + + public function testStaleSourceTreeRevCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch'); + self::assertSame([], $this->specs); + } + + public function testNeedsPublishPostsAndTheDiviRole(): void + { + self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden'); + LibraryFixtures::given([$this->page], ['publish_posts']); + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertSame([], $this->specs); + } + + public function testEditorCannotSaveACodeModule(): void + { + $this->page = LibraryFixtures::page( + '' + . '' + . '' + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result))); + self::assertSame([], $this->specs); + } + + public function testUnknownCategoryCreatesNothing(): void + { + $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->specs); + } + + /** Ruling P6: the layout write failing rolls the new entry back through NewDraft (write_failed). */ + public function testFailedLayoutWriteDeletesTheEntry(): void + { + Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(true); + + try { + $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertToolError($e->toError(), 'write_failed'); + } + self::assertSame([], $this->log->records); + } + + /** Ruling P6: when the rollback fails too, the answer is partial_write and the change log says so. */ + public function testFailedRollbackIsAPartialWrite(): void + { + Functions\when('wp_delete_post')->justReturn(false); + $this->log = FakeChangeLog::any(4711); + + try { + $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + self::assertToolError($e->toError(), 'partial_write'); + } + self::assertSame('et_pb_layout', $this->log->partials[0]['object_type']); + self::assertSame('2001', $this->log->partials[0]['object_id']); + } + + /** Ruling P7: scope "global" also needs Divi's edit_global_library. */ + public function testGlobalScopeNeedsEditGlobalLibrary(): void + { + $input = $this->input(['id' => 's0.r0.c0.m0', 'scope' => 'global']); + + self::assertToolError($this->ability(['edit_global_library'])->permission($input), 'forbidden'); + self::assertToolError($this->ability(['edit_global_library'])->execute($input), 'forbidden'); + self::assertTrue($this->ability(['edit_global_library'])->permission($this->input())); + self::assertSame([], $this->specs); + } + + public function testTheSourcePageIsNeverWritten(): void + { + $this->ability()->execute($this->input(['scope' => 'normal'])); + + self::assertSame([], $this->saved); + self::assertCount(1, $this->contents); + self::assertSame(2001, $this->contents[0][0]); + } + + /** Line breaks between blocks are unified like LibraryContent::load() (fixture finding 9). */ + public function testLineBreaksBetweenBlocksAreUnified(): void + { + $this->page = LibraryFixtures::page( + "\r\n\r\n" + . '' + . "\r\n\r\n" + ); + LibraryFixtures::given([$this->page]); + + $result = $this->ability()->execute($this->input(['id' => 's0'])); + + self::assertTrue($result['ok']); + self::assertStringNotContainsString("\r", $this->contents[0][1]); + self::assertStringContainsString("\n", $this->contents[0][1]); + } + + public function testUnknownScopeCreatesNothing(): void + { + self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed'); + self::assertSame([], $this->specs); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php index 3c749a5..f11d05a 100644 --- a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -62,4 +62,26 @@ final class NewDraftTest extends TestCase self::assertSame(NewDraft::REASON_EXTRAS, $e->reason); } } + + /** lit/library-save reuses the transaction without a PostWriter and names its entry (ruling P6). */ + public function testWithoutAWriterOnlyTheEntryIsDeletedAndPartialsNameTheEntry(): void + { + Functions\when('wp_delete_post')->justReturn(false); + $log = FakeChangeLog::partialsOnly(); + $draft = new NewDraft(null, $log, 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag'); + + try { + $draft->discard(2001, new RuntimeException('layout kaputt'), 'Grund.'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(2001, $e->postId); + } + $log->assertPartials([ + 'ability' => 'lit/library-save', + 'object_type' => 'et_pb_layout', + 'object_id' => '2001', + 'summary' => 'Bibliothekseintrag 2001: Anlegen nur teilweise zurückgenommen', + ]); + } } From 7fe91c2bdba8f3a528b244b9a36eb71b2baadbae Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 02:27:16 +0200 Subject: [PATCH 23/47] feat: lit-library-insert as copy or global instance in the builder's format (E5) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 10 +- .../Divi/Library/LibraryAccess.php | 10 + .../Divi/Library/LibraryInsertAbility.php | 271 ++++++++++++++++++ .../Divi/Library/LibraryIntegration.php | 8 +- src/Plugin.php | 2 +- .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../Divi/Library/LibraryAccessTest.php | 14 + .../Divi/Library/LibraryInsertAbilityTest.php | 269 +++++++++++++++++ 9 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryInsertAbility.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ebe14a2..de64acd 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -21,7 +21,7 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, -`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`. +`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`. Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 221f67a..82986f1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -116,8 +116,11 @@ final class DiviIntegration implements Integration ]; } - /** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */ - public function library(): LibraryIntegration + /** + * The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access; + * $diviWritable is whether lit/divi-insert is enabled (lit-library-insert follows it, audit M2). + */ + public function library(?Closure $diviWritable = null): LibraryIntegration { $loader = $this->loader(); @@ -128,7 +131,8 @@ final class DiviIntegration implements Integration $this->writer($loader), new DiviSaver(), $this->log, - $this->modules + $this->modules, + $diviWritable ); } diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index 83932b3..271715a 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -148,6 +148,16 @@ class LibraryAccess return $this->roles->refusal('edit_global_library'); } + /** + * Inserting from the library into a page (E3): canList(), the role option add_library (Divi's + * "Aus der Bibliothek hinzufügen") and a group "Divi-Bibliothek" that is not read-only or + * disabled (E9). Reading the entry itself stays item() (ruling P5). + */ + public function canInsert(): ?WP_Error + { + return $this->canList() ?? $this->roles->refusal('add_library') ?? $this->writableGate(); + } + /** Refusal while the Tools tab keeps this connection from writing to the library. */ public function writableGate(): ?WP_Error { diff --git a/src/Integrations/Divi/Library/LibraryInsertAbility.php b/src/Integrations/Divi/Library/LibraryInsertAbility.php new file mode 100644 index 0000000..08462c8 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryInsertAbility.php @@ -0,0 +1,271 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'], + 'mode' => ['type' => 'string', 'enum' => self::MODES], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'library_id'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + protected function precheck(array $input): ?WP_Error + { + $refusal = $this->access->canInsert(); + if ($refusal !== null) { + return $refusal; + } + + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $mode = $input['mode'] ?? null; + if ($mode !== null && ! in_array($mode, self::MODES, true)) { + return Errors::invalidInput('mode ist „copy“ oder „global“; nichts wurde gespeichert.', [ + 'mode' => 'copy oder global', + ]); + } + + $item = $this->item($input['library_id'] ?? null); + if ($item instanceof WP_Error) { + return $item; + } + + $id = (int) $item->ID; + $global = $this->access->isGlobal($item); + $mode = is_string($mode) ? $mode : ($global ? 'global' : 'copy'); + if ($mode === 'global' && ! $global) { + return Errors::invalidInput( + 'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.', + ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden'] + ); + } + + $blocks = $this->blocks($item, $mode === 'global', $global); + if ($blocks instanceof WP_Error) { + return $blocks; + } + + $guard = $composition->storedGuard(); + foreach ($blocks as $block) { + $problems = $guard->problems($block); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; ' + . 'nichts wurde gespeichert.', + ['library_id' => implode('; ', $problems)] + ); + } + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, $blocks); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.', + ['library_id' => $violation] + ); + } + + $change = new DiviChange(sprintf( + 'Bibliothekseintrag „%s“ (%d) %s eingefügt', + (string) $item->post_title, + $id, + $mode === 'global' ? 'als globale Instanz' : 'als Kopie' + )); + foreach ($blocks as $block) { + $change->record($block, 'inserted'); + } + if ($mode === 'global') { + $change->warning(sprintf( + 'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit ' + . 'diesem Modul (lit-divi-outline mit post_id %d).', + $id, + $id + )); + } + + return $change; + } + + /** + * The active library entry $libraryId after LibraryAccess' read checks (ruling P5: the library + * right and edit_post on the entry), or the refusal. Anything else is not found. + */ + private function item(mixed $libraryId): WP_Post|WP_Error + { + $id = PostGuard::id($libraryId); + if ($id === null) { + return Errors::invalidInput('library_id fehlt oder ist keine ID; nichts wurde gespeichert.', [ + 'library_id' => 'ID aus lit/library-list', + ]); + } + + $item = $this->access->item($id, false); + if ($item instanceof WP_Error) { + return $item; + } + + if (! $item instanceof WP_Post || (string) $item->post_status !== 'publish') { + return Errors::notFound( + sprintf('Bibliothekseintrag %d gibt es nicht (oder er ist nicht aktiv).', $id), + 'Einträge mit lit/library-list auflisten.' + ); + } + + return $item; + } + + /** @return list|WP_Error The blocks to insert. */ + private function blocks(WP_Post $item, bool $instance, bool $global): array|WP_Error + { + $id = (int) $item->ID; + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + return Errors::unsupported( + sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id), + 'Den Eintrag im Divi-Builder öffnen und speichern.' + ); + } + + if ($roots === []) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id), + ['library_id' => 'leer'] + ); + } + + if ($instance) { + return $this->instance($roots, $id); + } + + return array_map( + static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(), + $roots + ); + } + + /** + * The one instance of entry $id (GlobalInstance::of). Ruling P11: never nested instances – the + * root must be a single readable node holding no global module (Divi forbids nesting, F10). + * + * @param list $roots + * @return list|WP_Error + */ + private function instance(array $roots, int $id): array|WP_Error + { + if (count($roots) !== 1) { + return Errors::invalidInput( + 'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde ' + . 'gespeichert.', + ['mode' => 'mode "copy" verwenden'] + ); + } + + $root = $roots[0]; + if ($root->isOpaque() || LibraryContent::containsGlobal($root)) { + return Errors::invalidInput( + sprintf( + 'Bibliothekseintrag %d ist selbst ein globales Modul, enthält eines oder ist nicht lesbar; als ' + . 'Instanz entstünde eine Verschachtelung, die Divi nicht erlaubt. Nichts wurde gespeichert.', + $id + ), + ['mode' => 'mode "copy" verwenden oder den Eintrag im Divi-Builder prüfen'] + ); + } + + return [GlobalInstance::of($root, $id)]; + } +} diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index 34dc133..c32fd32 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use Closure; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -15,13 +16,14 @@ use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** - * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, later lit-library-insert. + * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct). * Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates – * its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the * Divi 5 builder, like the Divi group. */ final class LibraryIntegration implements Integration { + /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */ public function __construct( private LibraryAccess $access, private DiviPageLoader $loader, @@ -29,7 +31,8 @@ final class LibraryIntegration implements Integration private DiviWriter $writer, private DiviSaver $saver, private ChangeLog $log, - private DiviModules $modules + private DiviModules $modules, + private ?Closure $diviWritable = null ) { } @@ -60,6 +63,7 @@ final class LibraryIntegration implements Integration $this->log, $this->modules ), + new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable), ]; } } diff --git a/src/Plugin.php b/src/Plugin.php index 2649e96..e256ff5 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -95,7 +95,7 @@ final class Plugin new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), $divi, - $divi->library() + $divi->library($diviWritable) ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index a239cb9..7f6c4a1 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -58,7 +58,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi-Bibliothek', $library->label()); self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/library-list', 'lit/library-save'], + ['lit/library-list', 'lit/library-save', 'lit/library-insert'], array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities()) ); foreach ($library->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index 2e425a0..3044039 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -166,4 +166,18 @@ final class LibraryAccessTest extends TestCase self::assertToolError(LibraryFixtures::access(['edit_global_library'])->canSave(true), 'forbidden'); self::assertNull(LibraryFixtures::access()->canSave(true)); } + + /** Inserting from the library (E3): canList(), Divi's add_library and a group that is not read-only. */ + public function testInsertingNeedsAddLibrary(): void + { + LibraryFixtures::given([]); + + self::assertNull(LibraryFixtures::access()->canInsert()); + self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canInsert(), 'forbidden'); + self::assertToolError(LibraryFixtures::access([], false)->canInsert(), 'forbidden'); + + LibraryFixtures::given([], ['edit_posts']); + self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden'); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php new file mode 100644 index 0000000..613ce6e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php @@ -0,0 +1,269 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + LibraryFixtures::given(array_merge( + [$this->page], + array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)) + )); + } + + /** @param list $deniedRoles */ + private function ability(array $deniedRoles = [], bool $diviWritable = true, bool $unfiltered = false): LibraryInsertAbility + { + $access = LibraryFixtures::access($deniedRoles); + $failures = []; + + return new LibraryInsertAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, LibraryFixtures::loader($access)), + $access, + static fn (): bool => $diviWritable + ); + } + + /** + * @param array $extra + * @return array + */ + private function input(array $extra): array + { + return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra); + } + + private static function fixtureInstance(int $id, string $end): string + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''), $this->saved[0]); + self::assertSame('g0', $result['changed'][0]['id']); + self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings'])); + } + + public function testInsertsAGlobalModuleIntoAColumn(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]); + } + + public function testUntouchedContentKeepsItsBytes(): void + { + $before = (string) $this->page->post_content; + + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1'])); + + $instance = self::fixtureInstance(1059, '/-->'); + $at = (int) strpos($this->saved[0], $instance); + self::assertSame($before, substr($this->saved[0], 0, $at) . substr($this->saved[0], $at + strlen($instance))); + } + + public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1055])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString("\r", $this->saved[0]); + } + + public function testLayoutCopyInsertsEverySection(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy'])); + + self::assertCount(3, $result['changed']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + /** F10: a copy of a global entry is deglobalized like Divi's /load – its root bytes, no globalParent. */ + public function testCopyOfAGlobalEntryIsItsPlainRoot(): void + { + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + $section = str_replace( + ['', '', "\r\n"], + ['', '', "\n"], + DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html') + ); + self::assertTrue($result['ok']); + self::assertStringContainsString($section, $this->saved[0]); + self::assertStringNotContainsString('globalParent', $this->saved[0]); + self::assertStringNotContainsString('global-layout', $this->saved[0]); + self::assertSame([], $result['warnings']); + } + + /** F10: nested instances in a (stored) global entry become plain nodes in the copy. */ + public function testCopyDeglobalizesNestedInstances(): void + { + $instance = self::fixtureInstance(1059, '/-->'); + $this->givenItem(1058, '' + . '' + . $instance . '' + . ''); + + $result = $this->ability()->execute($this->input(['library_id' => 1058, 'mode' => 'copy'])); + + self::assertTrue($result['ok']); + self::assertStringNotContainsString('globalModule', $this->saved[0]); + self::assertStringContainsString(''); + foreach ( + [ + '' . $instance . '', + '' + . '' + . $instance . '' + . '', + '', + '', + ] as $content + ) { + $this->givenItem(1059, $content); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1059])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Verschachtelung', $result->get_error_message()); + } + self::assertSame([], $this->saved); + } + + public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void + { + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found'); + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'link'])), 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testATrashedEntryIsNotFound(): void + { + $this->givenItem(1055, DiviFixtures::markup('stage3/library--library-abschnitt.html'), ['post_status' => 'trash']); + + self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055])), 'not_found'); + self::assertSame([], $this->saved); + } + + /** Ruling P5: the entry is read through LibraryAccess – edit_post on it, not read_post. */ + public function testNeedsEditPostOnTheEntry(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && (int) ($args[0] ?? 0) === 1058) + ); + + $result = $this->ability()->execute($this->input(['library_id' => 1058])); + + self::assertToolError($result, 'forbidden'); + self::assertStringNotContainsString('[Fixture]', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testNeedsAddLibraryAndAWritableDiviGroup(): void + { + self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability(['add_library'])->execute($this->input(['library_id' => 1058])), 'forbidden'); + self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported'); + self::assertToolError($this->ability([], false)->execute($this->input(['library_id' => 1058])), 'unsupported'); + self::assertTrue($this->ability()->permission($this->input(['library_id' => 1058]))); + self::assertTrue($this->ability()->isDirect()); + self::assertSame([], $this->saved); + } + + public function testTheTargetKeepsTheTreeRevLockAndStructureRules(): void + { + self::assertToolError( + $this->ability()->execute(['post_id' => 7, 'tree_rev' => 'alt', 'library_id' => 1058]), + 'rev_mismatch' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1058, 'parent' => 's0.r0.c1'])), + 'validation_failed' + ); + self::assertToolError( + $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's9'])), + 'validation_failed' + ); + self::assertSame([], $this->saved); + } + + public function testEditorCannotInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability()->execute($this->input(['library_id' => 1080])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAdminMayInsertAnItemWithCustomCss(): void + { + $custom = LibraryFixtures::item(1055, [ + 'ID' => 1080, + 'post_content' => '', + ]); + LibraryFixtures::given([$this->page, $custom]); + + $result = $this->ability([], true, true)->execute($this->input(['library_id' => 1080])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } +} From 6f6c691b5d3554cb3b7b05e735b6b298b9d899eb Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 03:12:57 +0200 Subject: [PATCH 24/47] feat: lit/divi-node-copy between pages and library entries Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 7 +- .../Divi/Pages/DiviNodeCopyAbility.php | 141 ++++++++++ src/Plugin.php | 8 +- .../Integrations/Divi/DiviIntegrationTest.php | 20 ++ .../Divi/Pages/DiviNodeCopyAbilityTest.php | 261 ++++++++++++++++++ 6 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviNodeCopyAbility.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index de64acd..2cec51e 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 82986f1..b3493e1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -21,6 +21,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; @@ -53,12 +54,15 @@ final class DiviIntegration implements Integration * (group "Divi-Bibliothek" writable); null = always. * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group * "Divi-Bibliothek" not disabled, ruling P9); null = always. + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy + * follows it (ruling P9, E9); null = always. */ public function __construct( private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null, - ?Closure $libraryVisible = null + ?Closure $libraryVisible = null, + private ?Closure $diviWritable = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -113,6 +117,7 @@ final class DiviIntegration implements Integration new DiviDeleteAbility($writer), new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), + new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), ]; } diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php new file mode 100644 index 0000000..cc6e8a7 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -0,0 +1,141 @@ + 'object', + 'properties' => array_merge(DiviSchema::write(), [ + 'source_post_id' => ['type' => 'integer', 'minimum' => 1], + 'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'], + 'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'], + ], DiviSchema::position()), + 'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'], + 'additionalProperties' => false, + ]; + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** lit/divi-insert's Tools-tab state, then the source's own rights – in permission() and execute(). */ + protected function precheck(array $input): ?WP_Error + { + if ($this->diviWritable !== null && ! ($this->diviWritable)()) { + return Errors::unsupported( + 'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder ' + . 'lit-divi-insert deaktiviert).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + $source = $this->loader->load($input['source_post_id'] ?? null, false); + + return $source instanceof WP_Error ? $source : null; + } + + protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error + { + $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } + + if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) { + return Errors::revMismatch( + 'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde ' + . 'gespeichert.', + 'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.' + ); + } + + $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : ''; + $node = $source->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $source->post); + } + + $copy = $node->copy(); + $problems = $composition->storedGuard()->problems($copy); + if ($problems !== []) { + return Errors::invalidInput( + 'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde ' + . 'gespeichert.', + ['source_id' => implode('; ', $problems)] + ); + } + + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + return DiviErrors::position($position); + } + + $violation = $editor->insert($position, [$copy]); + if ($violation !== null) { + return Errors::invalidInput( + 'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.', + ['source_id' => $violation] + ); + } + + return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID))) + ->record($copy, 'inserted'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e256ff5..cc3f649 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -89,7 +89,13 @@ final class Plugin $libraryWritable = static function () use (&$policy): bool { return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); }; - $divi = new DiviIntegration($schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible); + $divi = new DiviIntegration( + $schema, + $changeLog, + $libraryWritable, + libraryVisible: $libraryVisible, + diviWritable: $diviWritable + ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 7f6c4a1..e5701b5 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -29,6 +29,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-delete', 'lit/divi-duplicate-node', 'lit/divi-replace-text', + 'lit/divi-node-copy', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void @@ -67,6 +68,25 @@ final class DiviIntegrationTest extends TestCase } } + /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */ + public function testNodeCopyFollowsLitDiviInsert(): void + { + $integration = new DiviIntegration( + Mockery::mock(ContentSchema::class), + FakeChangeLog::forbidden(), + diviWritable: static fn (): bool => false + ); + $copy = null; + foreach ($integration->abilities() as $ability) { + if ($ability->name() === 'lit/divi-node-copy') { + $copy = $ability; + } + } + + self::assertNotNull($copy); + self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported'); + } + public function testGivesContentCreateTheDiviShortForm(): void { $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php new file mode 100644 index 0000000..c07980b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -0,0 +1,261 @@ + */ + private array $saved = []; + + protected function setUp(): void + { + parent::setUp(); + $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html')); + $this->source = LibraryFixtures::page( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + ['ID' => 8, 'post_title' => 'Quelle'] + ); + LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]); + } + + private function ability( + bool $diviWritable = true, + bool $unfiltered = false, + ?LibraryAccess $access = null + ): DiviNodeCopyAbility { + $access ??= LibraryFixtures::access(); + $loader = LibraryFixtures::loader($access); + $failures = []; + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('of')->andReturn(['items' => [], 'count' => 0]); + $globals = new GlobalEdits($access, $usages); + + return new DiviNodeCopyAbility( + DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), + $loader, + static fn (): bool => $diviWritable + ); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge([ + 'post_id' => 7, + 'tree_rev' => Rev::of((string) $this->target->post_content), + 'source_post_id' => 8, + 'source_tree_rev' => Rev::of((string) $this->source->post_content), + 'source_id' => 's1', + 'after' => 's0', + ], $extra); + } + + private function sourceBytes(string $id): string + { + return Serializer::block( + NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find($id) + ); + } + + public function testCopiesANodeByteForByteBetweenPages(): void + { + $bytes = $this->sourceBytes('s1'); + + $result = $this->ability()->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertCount(1, $this->saved, 'only the target is written'); + self::assertStringContainsString($bytes, $this->saved[0]); + self::assertSame('s1', $result['changed'][0]['id']); + self::assertFalse($this->ability()->isDirect()); + } + + public function testLeavesTheRestOfTheTargetByteIdentical(): void + { + $original = (string) $this->target->post_content; + $bytes = $this->sourceBytes('s1'); + + $this->ability()->execute($this->input()); + + self::assertSame($original, str_replace($bytes, '', $this->saved[0])); + self::assertSame( + DiviFixtures::markup('stage3/page--globale-instanzen.html'), + (string) $this->source->post_content, + 'the source is only read' + ); + } + + public function testGlobalInstancesStayLinked(): void + { + $result = $this->ability()->execute($this->input(['source_id' => 'g0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability()->execute($this->input(['source_id' => 's0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testAnAdminMayCopyBuilderOnlyValues(): void + { + $this->source = LibraryFixtures::page( + '' + . '', + ['ID' => 8] + ); + LibraryFixtures::given([$this->target, $this->source]); + + $result = $this->ability(unfiltered: true)->execute($this->input(['source_id' => 's0'])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('body{display:none}', $this->saved[0]); + } + + public function testTheSourceNeedsItsOwnRights(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8) + ); + + self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); + self::assertToolError($this->ability()->execute($this->input()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testFollowsLitDiviInsertExposure(): void + { + $ability = $this->ability(diviWritable: false); + + self::assertToolError($ability->permission($this->input()), 'unsupported'); + self::assertToolError($ability->execute($this->input()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testCopiesIntoALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1055, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.m0', + 'after' => null, + 'parent' => 's0.r0.c0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]); + } + + public function testCopiesFromALibraryEntry(): void + { + $item = LibraryFixtures::item(1055); + + $result = $this->ability()->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertTrue($result['ok']); + self::assertStringContainsString('Library-Blurb', $this->saved[0]); + } + + public function testAHiddenLibraryIsNoSource(): void + { + $item = LibraryFixtures::item(1055); + $access = new LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + static fn (): bool => true, + static fn (): bool => false + ); + + $result = $this->ability(access: $access)->execute($this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0', + ])); + + self::assertToolError($result, 'not_found'); + self::assertSame([], $this->saved); + } + + public function testNeverNestsAnInstanceInAGlobalEntry(): void + { + $item = LibraryFixtures::item(1058); + LibraryFixtures::given([$this->target, $this->source, $item]); + + $result = $this->ability()->execute($this->input([ + 'post_id' => 1058, + 'tree_rev' => Rev::of((string) $item->post_content), + 'source_id' => 's0.r0.c0.g0', + 'after' => null, + 'parent' => 's0.r0.c0', + 'confirm' => true, + ])); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('keine globalen Module', $result->get_error_message()); + self::assertSame([], $this->saved); + } + + public function testRefusesAPlaceThatBreaksTheStructure(): void + { + $result = $this->ability()->execute($this->input(['after' => null, 'parent' => 's0.r0.c0'])); + + self::assertToolError($result, 'validation_failed'); + self::assertSame([], $this->saved); + } + + public function testRefusesAMissingSourceId(): void + { + self::assertToolError($this->ability()->execute($this->input(['source_id' => null])), 'not_found'); + self::assertSame([], $this->saved); + } +} From 8808a7dcdbc4b64e5b363a3821c879472afbe9be Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 03:22:13 +0200 Subject: [PATCH 25/47] feat: lit/divi-dynamic-sources with text-like fields only (E12, F4) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 3 + .../Divi/Pages/DiviDynamicSourcesAbility.php | 294 ++++++++++++++++++ .../Divi/Pages/DynamicContentMap.php | 47 ++- .../Integrations/Divi/DiviIntegrationTest.php | 1 + .../Pages/DiviDynamicSourcesAbilityTest.php | 163 ++++++++++ .../Divi/Pages/DynamicContentMapTest.php | 67 ++++ 7 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 2cec51e..29aad7e 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index b3493e1..d94676e 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -19,6 +19,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; @@ -29,6 +30,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -118,6 +120,7 @@ final class DiviIntegration implements Integration new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), + new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), ]; } diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php new file mode 100644 index 0000000..056109e --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -0,0 +1,294 @@ +}}. + * + * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods + * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each + * left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The + * field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25). + */ +final class DiviDynamicSourcesAbility implements Ability +{ + /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ + private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice']; + + private const ID_TYPES = ['relation', 'file']; + + /** Candidates the default sample lookup checks for edit_post. */ + private const SAMPLE_CANDIDATES = 10; + + /** @var Closure(string): int */ + private Closure $samplePost; + + /** @var Closure(string): ?string */ + private Closure $podStorage; + + /** + * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). + * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"); + * null without a pod. + */ + public function __construct( + private ContentSchema $schema, + private DynamicContentMap $map, + private DiviRoles $roles, + ?Closure $samplePost = null, + ?Closure $podStorage = null + ) { + $this->samplePost = $samplePost ?? self::defaultSample(...); + $this->podStorage = $podStorage ?? self::defaultStorage(...); + } + + public function name(): string + { + return 'lit/divi-dynamic-sources'; + } + + public function label(): string + { + return 'Dynamic-Content-Quellen'; + } + + public function description(): string + { + return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' + . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' + . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + $entry = static fn (array $properties): array => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => array_fill_keys($properties, ['type' => 'string']), + 'required' => $properties, + ], + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'post_type' => ['type' => 'string'], + 'sample_post_id' => ['type' => 'integer'], + 'sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], + ], + ], + 'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']), + 'omitted' => $entry(['key', 'label', 'reason']), + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $sample = ($this->samplePost)($postType); + $notes = [ + 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' + . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', + ]; + + $options = $this->map->options($sample); + if ($options === null) { + $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; + $options = []; + foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { + $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; + } + } + + $sources = []; + foreach ($options as $name => $option) { + $sources[] = [ + 'name' => (string) $name, + 'label' => $option['label'], + 'group' => $option['group'], + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($option['settings']), + ]; + } + + $fields = []; + $omitted = []; + if ($this->roles->allows('read_dynamic_content_custom_fields')) { + [$fields, $omitted] = $this->fields($postType); + } else { + $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' + . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; + } + + return [ + 'post_type' => $postType, + 'sample_post_id' => $sample, + 'sources' => $sources, + 'fields' => $fields, + 'omitted' => $omitted, + 'notes' => $notes, + ]; + } + + private static function placeholder(string $name): ?string + { + $post = array_search($name, Placeholders::POST, true); + if ($post !== false) { + return '{{post:' . $post . '}}'; + } + + return str_starts_with($name, 'post_link_url_') ? '{{link:}}' : null; + } + + /** + * @return array{0: list>, 1: list>} Offered and omitted fields. + */ + private function fields(string $postType): array + { + $storage = ($this->podStorage)($postType); + $fields = []; + $omitted = []; + foreach ($this->schema->fields($postType) as $field) { + if ($field->status === FieldDefinition::HIDDEN) { + continue; + } + + $entry = ['key' => $field->key, 'label' => $field->label]; + $reason = self::omission($field, $storage); + if ($reason !== null) { + $omitted[] = $entry + ['reason' => $reason]; + continue; + } + + $fields[] = $entry + [ + 'type' => $field->type, + 'source' => $field->source, + 'placeholder' => '{{field:' . $field->key . '}}', + ]; + } + + return [$fields, $omitted]; + } + + private static function omission(FieldDefinition $field, ?string $storage): ?string + { + return match (true) { + ! DynamicContentRules::isMetaKey($field->key) + => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', + $field->source === 'pods' && $storage === 'table' + => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', + in_array($field->type, self::ID_TYPES, true) + => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', + $field->type === 'unknown' + => 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text', + ! in_array($field->type, self::TEXT_TYPES, true) + => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), + default => null, + }; + } + + /** The most recently modified entry of the type the current user may edit, else 0. */ + private static function defaultSample(string $postType): int + { + $ids = get_posts([ + 'post_type' => $postType, + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => self::SAMPLE_CANDIDATES, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + foreach (is_array($ids) ? $ids : [] as $id) { + if (current_user_can('edit_post', (int) $id)) { + return (int) $id; + } + } + + return 0; + } + + private static function defaultStorage(string $postType): ?string + { + if (! function_exists('pods_api')) { + return null; + } + + try { + $pod = pods_api()->load_pod(['name' => $postType]); + } catch (Throwable) { + return null; + } + + return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 941ef6c..e2457e8 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -43,6 +43,51 @@ class DynamicContentMap /** @return array>|null Null = use DynamicContentRules' default map. */ public function forPost(int $postId): ?array + { + $raw = $this->raw($postId); + + return $raw === null ? null : self::fromOptions($raw); + } + + /** + * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same + * names and types as forPost(), loop_* left out). custom_meta_ options a filter registers + * for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so + * the listing never names them. Only label, group and setting types leave Divi's registry – + * no select options or defaults (they list meta keys). Null when the registry is unavailable. + * + * @return array}>|null + */ + public function options(int $postId): ?array + { + $raw = $this->raw($postId); + if ($raw === null) { + return null; + } + + $options = []; + foreach (self::fromOptions($raw) as $name => $settings) { + if (str_starts_with($name, 'custom_meta_') && ! DynamicContentRules::isMetaKey($name)) { + continue; + } + + $option = is_array($raw[$name] ?? null) ? $raw[$name] : []; + $options[$name] = [ + 'label' => is_string($option['label'] ?? null) ? $option['label'] : $name, + 'group' => is_string($option['group'] ?? null) ? $option['group'] : '', + 'settings' => $settings, + ]; + } + + return $options; + } + + /** + * Divi's registry for the post; any filter error is logged (message only) and yields null. + * + * @return array|null + */ + private function raw(int $postId): ?array { try { $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); @@ -52,7 +97,7 @@ class DynamicContentMap return null; } - return is_array($options) && $options !== [] ? self::fromOptions($options) : null; + return is_array($options) && $options !== [] ? $options : null; } /** diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index e5701b5..1d62b49 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -30,6 +30,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-duplicate-node', 'lit/divi-replace-text', 'lit/divi-node-copy', + 'lit/divi-dynamic-sources', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php new file mode 100644 index 0000000..b6726da --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php @@ -0,0 +1,163 @@ +justReturn( + (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] + ); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('fields')->with('page')->andReturn([ + 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), + 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), + 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), + 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), + '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), + 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), + 'gefunden' => new FieldDefinition('gefunden', 'gefunden', 'unknown', 'sample', FieldDefinition::UNKNOWN), + 'aktiv' => new FieldDefinition('aktiv', 'Aktiv', 'boolean', 'pods', FieldDefinition::WRITABLE), + 'kennwort' => new FieldDefinition('kennwort', 'Kennwort', 'password', 'pods', FieldDefinition::WRITABLE), + ]); + + return $schema; + } + + /** + * @param list $deniedRoles + * @param array|null $registry + */ + private function ability( + array $deniedRoles = [], + ?string $storage = 'meta', + ?array $registry = null + ): DiviDynamicSourcesAbility { + $registry ??= DiviFixtures::dynamicContentRegistry(); + + return new DiviDynamicSourcesAbility( + $this->schema(), + new DynamicContentMap(static fn (int $id): array => $registry), + LibraryFixtures::roles($deniedRoles), + static fn (string $postType): int => 831, + static fn (string $postType): ?string => $storage + ); + } + + public function testListsDiviSourcesWithTheirPlaceholders(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + $sources = array_column($result['sources'], null, 'name'); + self::assertSame(831, $result['sample_post_id']); + self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['site_title']['placeholder']); + self::assertArrayNotHasKey('loop_post_title', $sources); + self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); + self::assertFalse($this->ability()->isDirect()); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertSame('lit/divi-dynamic-sources', $this->ability()->name()); + } + + public function testOffersTextLikeFieldsAndNamesTheOthers(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); + self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); + $omitted = array_column($result['omitted'], 'reason', 'key'); + self::assertSame(['foto', 'projekte', '_intern', 'gefunden', 'aktiv', 'kennwort'], array_keys($omitted)); + self::assertStringContainsString('nur IDs', $omitted['foto']); + self::assertStringContainsString('nur IDs', $omitted['projekte']); + self::assertStringContainsString('„_“', $omitted['_intern']); + // Ruling P9: unknown field types are not offered. + self::assertStringContainsString('unbekannt', $omitted['gefunden']); + self::assertStringContainsString('nicht textartig', $omitted['aktiv']); + self::assertStringContainsString('nicht textartig', $omitted['kennwort']); + } + + public function testProtectedAndHiddenFieldsLeakNothing(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + $json = (string) json_encode($result); + + // Hidden fields are neither offered nor named (security audit M4). + self::assertStringNotContainsString('geheim', $json); + self::assertStringNotContainsString('Geheim', $json); + // The protected key is named (so the client knows why) but never offered with a placeholder. + self::assertStringNotContainsString('{{field:_intern}}', $json); + self::assertSame(['key', 'label', 'reason'], array_keys($result['omitted'][2])); + } + + public function testSourcesNeverNameProtectedMetaOptions(): void + { + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + ]; + + $result = $this->ability([], 'meta', $registry)->execute(['post_type' => 'page']); + + self::assertStringNotContainsString('_edit_lock', (string) json_encode($result)); + } + + public function testPodsTableStorageIsNotReadableByDivi(): void + { + $result = $this->ability([], 'table')->execute(['post_type' => 'page']); + + self::assertSame(['telefon'], array_column($result['fields'], 'key')); + self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); + } + + public function testFieldListNeedsTheDiviRole(): void + { + $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); + + self::assertSame([], $result['fields']); + self::assertSame([], $result['omitted']); + self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); + } + + public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void + { + $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); + + self::assertContains('post_title', array_column($result['sources'], 'name')); + self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); + } + + public function testUnknownPostTypeIsNotFound(): void + { + Functions\when('get_post_type_object')->justReturn(null); + + self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); + self::assertToolError($this->ability()->execute(['post_type' => 'nix']), 'not_found'); + } + + public function testNeedsTheTypesEditCapability(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + + self::assertToolError($this->ability()->permission(['post_type' => 'page']), 'forbidden'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 3136006..0c26f7a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -143,6 +143,73 @@ final class DynamicContentMapTest extends TestCase self::assertNull((new DynamicContentMap())->forPost(7)); } + public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']); + self::assertSame('post_meta_key', $options['post_meta_key']['label']); + self::assertSame('Standard', $options['post_meta_key']['group']); + self::assertArrayNotHasKey('loop_post_title', $options); + self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831)); + } + + public function testOptionsUseTheSameNamesAndTypesAsTheAllowMap(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(array_keys((array) $map->forPost(831)), array_keys((array) $options)); + $settings = array_map(static fn (array $option): array => $option['settings'], (array) $options); + self::assertSame($map->forPost(831), $settings); + // Placeholder options the registry lacks: name as label, no group. + self::assertSame(['label' => 'post_tags', 'group' => ''], array_slice($options['post_tags'], 0, 2)); + } + + public function testOptionsNeverNameProtectedMetaKeys(): void + { + // A third-party filter may register per-key options (D4 style custom_meta_). + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + 'custom_meta_custom_meta__secret' => ['label' => 'x', 'group' => 'Custom', 'fields' => []], + 'custom_meta_telefon' => ['label' => 'Telefon', 'group' => 'Custom', 'fields' => []], + ]; + $map = new DynamicContentMap(static fn (int $id): array => $registry); + + $options = (array) $map->options(831); + + self::assertArrayNotHasKey('custom_meta__edit_lock', $options); + self::assertArrayNotHasKey('custom_meta_custom_meta__secret', $options); + self::assertSame('Telefon', $options['custom_meta_telefon']['label']); + // The allow map itself is unchanged: VariableRef refuses those names by its own rules (S12/S30). + self::assertArrayHasKey('custom_meta__edit_lock', (array) $map->forPost(831)); + } + + public function testTheAllowMapIsUnchangedByTheRawLookupRefactor(): void + { + // Snapshot of forPost() for the live-like registry, taken before forPost() moved onto raw() + // (Task 15): the security allow map must stay exactly the same (rulings S22/S29/S30/S37). + $before = '{"post_title":{"before":"richtext","after":"richtext"},"post_excerpt":{"before":"richtext",' + . '"after":"richtext","words":"text","read_more_label":"text"},"post_date":{"before":"richtext",' + . '"after":"richtext","date_format":"select","custom_date_format":"text"},"post_author":{"before":' + . '"richtext","after":"richtext","name_format":"select","link":"yes_no_button"},"site_title":{"before":' + . '"richtext","after":"richtext"},"post_link_url":[],"post_link_url_page":{"post_id":"select_post"},' + . '"post_featured_image":[],"post_meta_key":{"before":"richtext","after":"richtext","select_meta_key":' + . '"select","meta_key":"text","enable_html":"yes_no_button"},"broken_fields":{"a":"","b":""},' + . '"no_fields":[],"post_modified_date":{"before":"richtext","after":"richtext","date_format":"select",' + . '"custom_date_format":"text"},"post_categories":{"before":"richtext","after":"richtext"},' + . '"post_tags":{"before":"richtext","after":"richtext"}}'; + + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + $after = $map->forPost(831); + + self::assertSame(json_decode($before, true), $after); + self::assertSame($before, json_encode($after)); + } + private static function assertRejected(callable $call, string $fragment): void { try { From 2729e2800e495a84f2ddb300ce018cfddef0ea8d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 03:33:38 +0200 Subject: [PATCH 26/47] docs: guides, readme and spec for the Divi Library (v0.4.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-attrs.md | 4 +- docs/guides/divi-design-system.md | 4 + docs/guides/divi-library.md | 103 ++++++++++++++++++ docs/guides/divi-placeholders.md | 17 ++- docs/guides/divi-shortform.md | 4 +- docs/guides/divi-structure.md | 9 +- docs/guides/overview.md | 10 +- docs/guides/workflows.md | 10 +- ...6-09-27-etappe-3-entscheidungen-entwurf.md | 59 ++++++++++ .../specs/2026-09-25-wp-lit-mcp-design.md | 19 ++-- readme.txt | 52 +++++++-- src/Guides/InstructionsBuilder.php | 14 ++- src/Plugin.php | 2 +- tests/Unit/Guides/InstructionsBuilderTest.php | 41 +++++++ wp-lit-mcp.php | 2 +- 15 files changed, 320 insertions(+), 30 deletions(-) create mode 100644 docs/guides/divi-library.md diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index ffe0f85..0fb25b6 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -41,7 +41,9 @@ Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute, Interaktionen und die Lottie-Quelle (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) nur mit dem Recht `unfiltered_html`, sonst „nur im -Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten +Divi-Builder“ – Interaktionen können im Browser beliebige HTML-Attribute und Cookies setzen, die +Lottie-Quelle lädt eine Animation von einer fremden Adresse, die Code ausführen kann. Dieselbe Grenze gilt +beim Kopieren und Einfügen gespeicherter Knoten (siehe `divi-library`). Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). ## Pfade finden diff --git a/docs/guides/divi-design-system.md b/docs/guides/divi-design-system.md index ab092c9..6bcf720 100644 --- a/docs/guides/divi-design-system.md +++ b/docs/guides/divi-design-system.md @@ -11,6 +11,10 @@ - **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls. +Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile +(Schriften und Größen sind immer dabei), `module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens +200 Presets je Antwort (`notes` sagt, wenn es mehr gibt). + Regeln: 1. Vor dem Gestalten einer Seite das Designsystem lesen. diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md new file mode 100644 index 0000000..09663dd --- /dev/null +++ b/docs/guides/divi-library.md @@ -0,0 +1,103 @@ +# Divi-Bibliothek und globale Module + +Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung. + +- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig. +- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des + Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf + veröffentlichten. + +## Werkzeuge + +- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` + (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` + (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: + ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen Einträgen die Seiten, die + ihn zeigen (`affected`, `affected_count`). +- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen + Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. + Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` + der Zielseite ist Pflicht. Die Kopie eines globalen Eintrags ist vollständig gelöst (auch darin enthaltene + globale Module); in der Kopie eines normalen Eintrags bleiben globale Module verknüpft. `mode: "global"` + geht nur bei globalen Einträgen mit genau einem Abschnitt, einer Zeile oder einem Modul ohne globale Module + darin – sonst `mode: "copy"` verwenden. Nur aktive Einträge (nicht im Papierkorb) lassen sich einfügen. +- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`post_id`, + `tree_rev`, Pfad-ID `id`, `title`, `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der + Knoten auf der Seite bleibt eine eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz + ersetzen (siehe Ablauf unten). Ein Knoten, der selbst ein globales Modul ist oder eines enthält, lässt sich + nicht als globaler Eintrag speichern. +- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren + (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle – Pflicht, weil + Pfad-IDs nur für diesen Stand gelten; Ziel mit `post_id`, `tree_rev`, Position). Quelle und Ziel dürfen + Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. +- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, + `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit/divi-duplicate-node` und + `lit/divi-replace-text` mit der ID des Eintrags als `post_id`. + +## Globale Module erkennen und ändern + +1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058] + (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite + lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. +2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. +3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf: Antwort `confirm_required` mit + `affected` (betroffene Seiten; Titel nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; + gespeichert wird nichts. +4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit + `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). +5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine + Sicherungskopie im Papierkorb (`backup_id`). Schlägt das Speichern danach fehl, nennt der Fehler die + `backup_id`. Nach dem Speichern wird Divis CSS-Cache für alle Seiten geleert. + +Die Bestätigung wird abgelehnt (`forbidden`), wenn das Konto einen veröffentlichten, privaten oder geplanten +Eintrag, der das Modul zeigt, nicht bearbeiten darf – schon der Probelauf warnt davor und nennt diese IDs in +`blocked_ids` (Entwürfe blockieren nicht). Sind Revisionen für die Bibliothek abgeschaltet, lehnt das +Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein +`unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei). + +`lit/divi-replace-text` nimmt Bibliothekseinträge als `targets` (ID und `tree_rev` aus `lit-divi-outline`; +der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale nie – ihr +Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update` +und `confirm: true` vornehmen. + +Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses +Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; kein Eintrag +wird leer; ein globaler Eintrag enthält keine globalen Module. Nur aktive Einträge lassen sich ändern. + +`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt +sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen. + +## Rechte + +- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen + Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge + sind immer aktiv). +- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“ (anlegen und ändern), „Aus der + Bibliothek hinzufügen“ (einfügen), „Globale Bibliothekselemente bearbeiten“ (globale Einträge anlegen und + ändern). Fehlt „Divi-Bibliothek“, gilt jeder Eintrag als nicht vorhanden (`not_found`) und die Liste + antwortet mit `forbidden`; fehlt eines der anderen, antwortet das Werkzeug mit `forbidden` und diesem + Hinweis. +- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf + Bibliothekseinträge, auch über die Divi-Werkzeuge; deaktiviert gilt jeder Eintrag als nicht vorhanden. + `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist. +- Ohne das Recht `unfiltered_html` überträgt LIT MCP beim Kopieren, Einfügen und Speichern (`lit/divi-node-copy`, + `lit-library-insert`, `lit/library-save`) nur Knoten, die das Konto auch selbst schreiben dürfte. Abgelehnt + werden – „nur im Divi-Builder“ – Knoten mit eigenem HTML, CSS oder HTML-Attributen, Interaktionen, einer + Lottie-Quelle, dem Code-Modul, fremden Blöcken oder Freitext zwischen den Blöcken, Text mit HTML, das der + HTML-Filter von WordPress verändern würde, und `$variable`-Verweisen, die die normale Prüfung nicht + bestehen (z. B. HTML davor/danach). Das ist strenger als Divi selbst; die Antwort nennt jede Fundstelle + (`validation_failed`), gespeichert wird nichts. Relative Links (`kontakt/`, `/pfad`, `#anker`) bleiben + kopierbar. + +## Ablauf: globale Module vorschlagen + +1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder + Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action). +2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt + danach auf allen Seiten. +3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern. +4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt + einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen. +5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen. + +Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt. diff --git a/docs/guides/divi-placeholders.md b/docs/guides/divi-placeholders.md index 571e0e9..8145596 100644 --- a/docs/guides/divi-placeholders.md +++ b/docs/guides/divi-placeholders.md @@ -12,6 +12,7 @@ sich auch der Button. | `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext | | `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags | | `{{field:}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` | +| `{{link:}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden | | `"preset": ""` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | Beispiele: @@ -30,9 +31,19 @@ Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen; ist ein Name d Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_` beginnt (geschützte Meta-Daten), sind nicht erlaubt. -In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`; -sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine -Link-Variable). +In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:}}` verwenden statt fester +Adressen wie `/?page_id=…`. Der Eintrag muss existieren und für das Konto lesbar sein; Einträge im Papierkorb, +automatische Entwürfe und Medien (Anhänge – dafür die Adresse aus der Mediathek) lehnt das Werkzeug ab, +ebenso `{{link:…}}` außerhalb von Link-Feldern. Solange das Ziel ein Entwurf oder privat ist, zeigt Divi +Besuchern nur die Adresse `?page_id=` (kein Titel); nach dem Veröffentlichen die normale Adresse. Sonst +nimmt ein Link-Feld jeden dynamischen Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben +(`{{post:url}}`, ein Feld mit einer URL, eine Link-Variable). + +Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources` +(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als +`{{field:…}}` eignen. Gelistet werden nur textartige Felder; Beziehungs- und Dateifelder (Divi zeigt sie nur +als IDs), Tabellen-Felder und Felder unbekannten Typs stehen mit Grund unter `omitted`. Bei Auswahlfeldern +zeigt Divi den gespeicherten Wert, nicht die Beschriftung. ## `$variable`-Werte diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index fabee59..f933779 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -107,7 +107,9 @@ Bedarf die Vorlage im Builder neu wählen). Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. `$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute, Interaktionen und die Lottie-Quelle eines Moduls (`…decoration.attributes`, -`…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der +`…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“ (Interaktionen können im +Browser beliebige HTML-Attribute und Cookies setzen, die Lottie-Quelle lädt Animationen von fremden Adressen, +die Code ausführen können). Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). diff --git a/docs/guides/divi-structure.md b/docs/guides/divi-structure.md index 53e42d8..1a70324 100644 --- a/docs/guides/divi-structure.md +++ b/docs/guides/divi-structure.md @@ -38,8 +38,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert ## Was nicht bearbeitet wird - Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, - duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später). - `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`. + duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global ]`: ihren Inhalt im + Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe + `lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in + `skipped_nodes`. - Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder öffnen und speichern, Divi 5 wandelt sie dabei um. - Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und @@ -49,4 +51,5 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert `lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel). -Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. +Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten; globale Module ebenfalls nicht (über die +Schnittstelle rendert Divi sie nicht) – `notes` nennt sie mit ihrer ID. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 29aad7e..a41a34b 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -36,12 +36,16 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, +`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). -Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. +Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Sie arbeiten auch auf Einträgen +der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`. Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, -`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. +`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`. + +Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf. ## Rückmeldungen diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md index 7ae6496..87734f8 100644 --- a/docs/guides/workflows.md +++ b/docs/guides/workflows.md @@ -22,6 +22,13 @@ 4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`). +## Bibliothek und globale Module + +Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert` +einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`. +Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen: +`lit-guide` topic `divi-library`. + ## Text auf vielen Seiten ersetzen `lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach @@ -29,7 +36,8 @@ Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. Ersetzt wird nur sichtbarer Text (keine Links, Stile, Code oder dynamischen Inhalte); in reinen Textfeldern (Titel, Button-Texte) werden Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht -geändert, sondern in `skipped_nodes` genannt – dem Nutzer sagen, dass er sie in der Divi-Bibliothek ändern muss. +geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic +`divi-library`). ## Menüs pflegen diff --git a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md index 7029bb0..f13c78d 100644 --- a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md +++ b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md @@ -160,3 +160,62 @@ Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantt - F3 Reihenfolge: erst 3a Bibliothek, dann 3b Theme Builder. - F4 Pods-Beziehungs-/Dateifelder: als dynamische Quelle nur textartige Felder anbieten; andere mit Hinweis auslassen. - F5 Testdaten: selbst erzeugen („Probier es selbst“) – über Divis eigene Speicherwege (Builder-REST-Controller/Services im Prozess), als „[Fixture] …“ markiert, danach als Fixtures ins Plugin exportiert. Pods ist lokal deaktiviert; Aktivierung für 3b beim Benutzer anfragen. + +## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md) + +- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` = + vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`, + ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen + `page--globale-instanzen.html`. +- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload); + `LibraryContent::load()` vereinheitlicht das vor jeder Übernahme (undurchsichtige Blöcke bleiben unberührt). +- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`); + entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10). `lit/library-save` + mit `scope: "normal"` übernimmt enthaltene Instanzen ebenfalls als Verweise. `mode: "global"` nur für + globale Einträge mit genau einer Wurzel ohne globale Module (P11: nie verschachtelte Instanzen). +- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch fremde Blöcke, + Freitext, nicht lesbare Blöcke, kses-instabilen Rich-Text (P3) und `$variable`-Verweise (Ablehnung, wenn + `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund: R2-Befund 1 + (before/after nach dem Autor des Ziel-Posts). Lesbare globale Instanzen werden geprüft (localAttrs und + Schnappschuss), nicht abgelehnt (P15). Strenger als Divi; sichtbare Ablehnung. URL-Werte: `url($v) === + trim($v)` oder relativer Link ohne Schema (P17/P18) – `kontakt/`, `/pfad`, `#anker` bleiben kopierbar. +- Builder-only erweitert (S7-Gate, gilt auch für Seiten): `decoration.interactions` (P16 – der Effekt + „Attribut“ setzt im Browser beliebige Attribute, z. B. `onclick`; „Cookie“ setzt Cookies) und + `lottie.innerContent` (P19 – `src` lädt fremdes JSON, lottie-web wertet Ausdrücke aus). Offen für + Etappe 4: Divi-Presets können beides tragen; Preset-Schreibwege brauchen dasselbe Gate. +- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`); + `scope: "global"` braucht zusätzlich `edit_global_library` (P7). Ohne das Divi-Recht „Divi-Bibliothek“ + (oder mit deaktivierter Gruppe, P9) ist jede Bibliotheks-ID `not_found`, byte-gleich mit einer fehlenden ID + (P12); ohne `edit_post` auf dem Eintrag `forbidden` ohne Titel. `lit/library-list` listet nur Einträge mit + `edit_post` und liest höchstens 500 Zeilen je Aufruf. +- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe + „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf + Bibliothekseinträge (`ExposurePolicy::groupWritable`). `lit-library-insert` und `lit/divi-node-copy` folgen + zusätzlich dem Zustand von `lit/divi-insert` (M2). Offen (P13, Schlusswelle): bei deaktivierter Gruppe + nennen Outline/node-get/Render-Auszug einer Seite noch die ID globaler Module. +- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte + Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die + betroffenen Seiten. P8a: die Bestätigung wird abgelehnt, wenn der Benutzer einen Eintrag mit Status + `publish`, `private` oder `future`, der den Eintrag zeigt, nicht bearbeiten darf (auch TB-Layouts und andere + Bibliothekseinträge); Entwürfe blockieren nicht. Der Probelauf nennt solche IDs vorab in `blocked_ids` + (P14). Revisionen aus: abgelehnt ohne `unfiltered_html` (die Sicherung liefe durch kses) oder bei + deaktiviertem Papierkorb (P9); scheitert das Speichern nach einer Sicherung, nennt der Fehler `backup_id` + (P14). Cache-Leerung für alle Seiten und den Theme Builder. +- P1: `lit/divi-replace-text` speichert normale Bibliothekseinträge über `DiviWriter::commit()`; globale nie + (die Ziele tragen kein `confirm`) – das Ergebnis nennt die betroffenen Seiten und verweist auf + `lit-divi-update` mit `confirm`. +- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5). +- E13a: `{{link:}}` nur in URL-Feldern; Ziel muss existieren, `read_post` bestehen, nicht Papierkorb, + `auto-draft` oder `inherit` sein – Anhänge sind damit ausgeschlossen (P10). Entwürfe und private Ziele + rendert Divi für Besucher als `?page_id=` (nur die ID wird sichtbar, Preflight). +- F4/E12: `lit/divi-dynamic-sources` listet nur textartige Felder; Beziehungs-/Dateifelder („nur IDs“), + Tabellen-Speicher und unbekannte Feldtypen (P9) stehen unter `omitted`; Auswahlfelder zeigen den + gespeicherten Wert, nicht die Beschriftung. Der Beispiel-Post ist nur einer, den der Benutzer bearbeiten + darf. +- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status` + (`problems` je Komponente). +- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt. +- Tests: die Unit-Suite läuft mit `memory_limit` 512M (`phpunit.xml.dist` und `composer test`), weil der + Divi-Katalog beim Standard von 128M nicht mehr in den Speicher passt. +- Server-Instructions: Zeile „Divi-Bibliothek: …“ nur bei aktiver, beschreibbarer Gruppe; MCP-Prompts gibt es + noch nicht, der Ablauf „globale Module vorschlagen“ steht im Guide `divi-library` (E14). diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index fe3b4a8..8ec3f0b 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -114,7 +114,7 @@ Ob dieser Aufbau stabil funktioniert, entscheidet Etappe 0 (§12). - **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set: `lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`. -- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). +- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). **Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“ (`lit/library-*`); `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“. - **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). - In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind. - Ability-Namen: `lit/-`. Der Adapter bildet daraus Tool-Namen (`lit--`). @@ -174,7 +174,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ - **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu, was nur Bytes ändert, die noch nicht in Divis `serialize_blocks()`-Form vorlagen; gemeldet als `CHANGED_ON_SAVE` (Ruling S7). - Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar). - Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). -- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert. +- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2). Unbalanciertes Markup wird abgelehnt, nie repariert. ### 5.3 Adressierung und optimistische Sperre @@ -216,6 +216,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ | `{{font:heading}}`, `{{font:body}}` | Theme-Schriften (`--et_global_heading_font` / `--et_global_body_font`) | | `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) | | `{{field:}}` | Dynamic Content `custom_meta_` (Pods/Meta) | +| `{{link:}}` | interner Link: Dynamic Content `post_link_url_` mit `post_id` (nur Link-Felder; Ziel muss für den Benutzer lesbar sein, nicht im Papierkorb, kein automatischer Entwurf, kein Anhang; Entwürfe/private Ziele rendert Divi als `?page_id=`; Etappe 3a, E13a) | | `"preset": ""` | `modulePreset: [""]` | Unbekannte Namen → Fehler mit Liste der vorhandenen. @@ -249,7 +250,7 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich | `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) | | `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul | | `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen | -| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) | +| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4): Beziehungs-, Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter `omitted`, Auswahlfelder liefern den gespeicherten Wert; Beispiel-Post nur einer, den der Benutzer bearbeiten darf | | `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) | | `lit/divi-insert` | schreiben | Knoten (Kurz-/Vollform, auch ganze Abschnitte) an Position `parent`/`before`/`after` einfügen | | `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) | @@ -257,10 +258,10 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich | `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen | | `lit/divi-duplicate-node` | schreiben | Knoten duplizieren | | `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) | -| `lit/library-list`, `lit/library-get` | lesen | Library-Einträge nach Typ (`layout_type`) und Scope | -| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global) | -| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen | -| `lit/divi-node-copy` | schreiben | Knoten von Seite A nach Seite B kopieren | +| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9); nur Einträge mit `edit_post` | +| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global; global braucht Divis „Globale Bibliothekselemente bearbeiten“, P7); Knoten auf der Seite bleibt unverändert; StoredNodeGuard (E6) | +| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt | +| `lit/divi-node-copy` | schreiben | Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, `source_tree_rev` Pflicht, S7/S26-Prüfung E6) | | `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln; optional gleichartige Vorkommen ersetzen | | `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) | | `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts | @@ -401,15 +402,17 @@ Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zei - aktive Integrationen und Post-Typen, - Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben, - Verweis auf `lit/guide`. +- Etappe 3a: Zeile „Divi-Bibliothek: …“ (globale Module im Bibliothekseintrag ändern, Probelauf und `confirm: true`, Guide `divi-library`), nur wenn die Gruppe „Divi-Bibliothek“ aktiv und beschreibbar ist. ### 9.2 `lit/guide(topic)` Direktes Tool; Anleitungen als Markdown in `docs/guides/`, mit dem Plugin versioniert, per Filter `lit_mcp_guides` erweiterbar. -Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `theme-builder`, `content-types`, `workflows`. +Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `divi-library`, `theme-builder`, `content-types`, `media`, `workflows`. Thema `all` liefert alle Themen in einem Aufruf (Etappe 3a). ### 9.3 MCP-Prompts `corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`. +(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.) ## 10. Sicherheit diff --git a/readme.txt b/readme.txt index 9593e5f..b239810 100644 --- a/readme.txt +++ b/readme.txt @@ -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.3.0 +Stable tag: 0.4.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -12,9 +12,9 @@ MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten. == Description == -WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth) diese Website pflegt: -Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene -WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen +WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (ein MCP-fähiger Client mit OAuth) diese +Website pflegt: Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene +WordPress-Einstellungen, die Mediathek sowie Divi-Seiten und die Divi-Bibliothek (auch globale Module). Jede Änderung läuft mit den Rechten des verbundenen WordPress-Benutzers und wird protokolliert. Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits). @@ -46,9 +46,11 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post, sanitize_text_field, esc_url_raw); $variable-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. - Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute - von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben - aktiv. + Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS, HTML-Attribute, + Interaktionen und die Lottie-Quelle von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. + Beim Kopieren, Einfügen und Speichern gespeicherter Knoten (Divi-Bibliothek, lit/divi-node-copy) lehnt + LIT MCP für Benutzer ohne unfiltered_html alles ab, was sie nicht selbst schreiben dürften – strenger als + Divi, mit sichtbarer Fehlermeldung. Divis eigene Speicherfilter bleiben aktiv. * Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke "), -strlen("")); +$page = $read("page--globale-instanzen"); +$from = strpos($page, "", $from) + 5 - $from); +$globalSection = substr($page, strlen(""), + strpos($page, "\n\n") + strlen("\n\n") - strlen("")); +$text = $unframe($read("library-global--globales-modul")); +switch (getenv("LIT_WHAT")) { + case "source": // the fixture page without global instances: the global module as a plain text module + echo str_replace([$globalSection, $instance], ["", $text], $page); break; + case "target": // the section "Modul Abschnitt" with only the local button + echo str_replace([$globalSection, $instance . "\n\n"], ["", ""], substr($page, 0, strpos($page, ""; break; + case "section-entry": echo str_replace("\r\n", "\n", $read("library--library-abschnitt")); break; + case "section": echo $unframe(str_replace("\r\n", "\n", $read("library--library-abschnitt"))); break; + case "module-entry": echo $read("library-global--globales-modul"); break; + case "instance": echo $instance; break; +}' +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" + +# 0. Temporary pages built from Divi's own stage3 bytes +SOURCE_ID=$(create_page "$(snippet source)" "Quelle"); CREATED_POSTS+=("$SOURCE_ID") +TARGET_ID=$(create_page "$(snippet target)" "Ziel"); CREATED_POSTS+=("$TARGET_ID") +SECTION_BYTES=$(snippet section) +content_of "$SOURCE_ID" | grep -qF "$SECTION_BYTES" || fail "setup: source page lacks the library section bytes" +if content_of "$SOURCE_ID" | grep -q 'globalModule'; then fail "setup: source page still holds a global instance"; fi +SOURCE_HASH=$(content_hash "$SOURCE_ID") +connect "$ADMIN_LOGIN" + +# 1. Exposure: lit-library-insert direct, the other new tools indirect, 22 direct lit tools +LIST=$(rpc tools/list '{}') +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint != true' >/dev/null \ + || fail "lit-library-insert is not a direct write tool" +for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do + if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi +done +DIRECT='["lit-content-create","lit-content-get","lit-content-query","lit-content-types","lit-content-update","lit-divi-catalog", +"lit-divi-delete","lit-divi-design-system","lit-divi-insert","lit-divi-move","lit-divi-node-get","lit-divi-outline","lit-divi-update", +"lit-guide","lit-library-insert","lit-media-download","lit-media-preview","lit-media-search","lit-media-upload-from-url", +"lit-page-render-summary","lit-post-publish","lit-site-status"]' +echo "$LIST" | jq -e --argjson want "$DIRECT" '[.result.tools[].name | select(startswith("lit-"))] | sort == ($want | sort) and length == 22' >/dev/null \ + || fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')" +for a in lit/library-list lit/library-save lit/divi-node-copy lit/divi-dynamic-sources; do + tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \ + || fail "$a is not reachable as an indirect ability" +done +pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect, 22 direct tools" + +# 2. Save a section as normal entry: bytes, terms and meta as Divi's own entry, no Divi flags, source untouched +REV=$(rev_of "$SOURCE_ID") +SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Abschnitt" \ + '{post_id: $id, tree_rev: $rev, id: "s1", title: $t}')") +LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty') +[ -n "$LIB_ID" ] || fail "library-save: $SAVED" +CREATED_POSTS+=("$LIB_ID") +[ "$(content_of "$LIB_ID")" = "$(snippet section-entry)" ] || fail "normal entry bytes differ from Divi's entry: $(content_of "$LIB_ID")" +[ "$(entry_shape "$LIB_ID" | jq -cS .)" = "$(fixture_shape library--library-abschnitt)" ] \ + || fail "normal entry terms/meta: $(entry_shape "$LIB_ID")" +[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags" +[ "$(wp post get "$LIB_ID" --field=post_status)" = "publish" ] || fail "library entry is not published" +echo "$SAVED" | jq -e --arg r "$(rev_of "$LIB_ID")" '.tree_rev == $r' >/dev/null || fail "library-save tree_rev: $SAVED" +[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "library-save changed the source page" +pass "lit/library-save normal: bytes, terms and meta like Divi's entry, no Divi flags, source unchanged" + +# 3. Save the text module as global entry; library-list shows both entries +GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Globales Modul" \ + '{post_id: $id, tree_rev: $rev, id: "s0.r0.c0.m0", title: $t, scope: "global"}')") +GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty') +[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED" +CREATED_POSTS+=("$GLOB_ID") +[ "$(content_of "$GLOB_ID")" = "$(snippet module-entry)" ] || fail "global entry bytes differ from Divi's entry: $(content_of "$GLOB_ID")" +[ "$(entry_shape "$GLOB_ID" | jq -cS .)" = "$(fixture_shape library-global--globales-modul)" ] \ + || fail "global entry terms/meta: $(entry_shape "$GLOB_ID")" +[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "global library-save changed the source page" +LISTED=$(ability lit/library-list "$(jq -cn --arg s "$PREFIX" '{search: $s}')") +echo "$LISTED" | jq -e --argjson l "$LIB_ID" --argjson g "$GLOB_ID" ' + (.items | map(select(.id == $l and .scope == "normal" and .layout_type == "section")) | length == 1) and + (.items | map(select(.id == $g and .scope == "global" and .layout_type == "module")) | length == 1)' >/dev/null \ + || fail "library-list: $LISTED" +ability lit/library-list "{\"id\":$GLOB_ID}" | jq -e '.outline | length > 0' >/dev/null || fail "library-list detail of $GLOB_ID" +pass "lit/library-save global: bytes, terms and meta like Divi's entry; lit/library-list shows both" + +# 4. Insert the global entry as instance, the normal entry as copy: bytes as the Visual Builder writes them +BEFORE=$(content_of "$TARGET_ID") +INST=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(rev_of "$TARGET_ID")" --argjson lib "$GLOB_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: "s0.r0.c0", index: 0}')") +echo "$INST" | jq -e '.ok == true and .changed[0].id == "s0.r0.c0.g0"' >/dev/null || fail "library-insert global: $INST" +EXPECTED_INSTANCE=$(snippet instance | sed "s/\"globalModule\":\"1059\"/\"globalModule\":\"$GLOB_ID\"/") +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$EXPECTED_INSTANCE")" = "yes" ] \ + || fail "instance bytes differ from the Visual Builder's: $(content_of "$TARGET_ID")" +outline "$TARGET_ID" | jq -e --arg g "$GLOB_ID" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance" +BEFORE=$(content_of "$TARGET_ID") +COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") +echo "$COPY" | jq -e '.ok == true and .changed[0].id == "s1"' >/dev/null || fail "library-insert copy: $COPY" +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "copy bytes differ from the entry's section" +if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi +pass "lit-library-insert: instance and copy byte-exact in the Visual Builder's format" + +# 5. Global edit: dry run lists the pages, confirm changes the entry with a revision, page bytes unchanged +PRIVATE_ID=$(create_page "$EXPECTED_INSTANCE" "Privat" private) +CREATED_POSTS+=("$PRIVATE_ID") +TARGET_HASH=$(content_hash "$TARGET_ID") +PRIVATE_HASH=$(content_hash "$PRIVATE_ID") +GLOB_HASH=$(content_hash "$GLOB_ID") +GREV=$(rev_of "$GLOB_ID") +CHANGE='[{"id":"m0","set":{"content":"

LIT Smoke geändert

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

Probe

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

LIT Smoke Redakteur

"}}]' +DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "editor dry run: $DRY" +echo "$DRY" | jq -r '.__error' | jq -e --argjson p "$PRIVATE_ID" '.blocked_ids == [$p]' >/dev/null || fail "dry run lacks blocked_ids: $DRY" +REFUSED=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')") +[ "$(error_code "$REFUSED")" = "forbidden" ] || fail "confirmed global edit with a blocked private page: $REFUSED" +echo "$REFUSED" | grep -q "$PRIVATE_ID" || fail "refusal does not name the private page ID: $REFUSED" +if echo "$REFUSED" | grep -q "$PREFIX Privat"; then fail "refusal reveals the private page's title"; fi +[ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "refused global edit wrote something" +pass "P8a: editor without rights on a private page gets blocked_ids in the dry run and forbidden on confirm" + +# 10b. Control: the editor may copy benign nodes and entries +TREV=$(rev_of "$TARGET_ID") +OK_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ + --arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')") +echo "$OK_COPY" | jq -e '.ok == true' >/dev/null || fail "editor node-copy of a benign section: $OK_COPY" +OK_INSERT=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$OK_COPY" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") +echo "$OK_INSERT" | jq -e '.ok == true' >/dev/null || fail "editor library-insert copy of a benign entry: $OK_INSERT" +pass "editor without unfiltered_html copies benign nodes and entries" + +# 10c. Interactions, Lottie source and custom HTML: refused by library-save, library-insert copy and node-copy +TARGET_HASH=$(content_hash "$TARGET_ID") +TREV=$(rev_of "$TARGET_ID") +PROBE_REV=$(rev_of "$PROBE_ID") +PROBE_PATHS=(decoration.interactions lottie.innerContent advanced.html) +refused() { # $1 = result, $2 = context, $3 = path the refusal must name + [ "$(error_code "$1")" = "validation_failed" ] || fail "$2 not refused: $1" + echo "$1" | grep -q 'Divi-Builder' && echo "$1" | grep -qF "$3" || fail "$2 refused for another reason: $1" +} +i=0 +for s in s0 s1 s2; do + p=${PROBE_PATHS[$i]} + refused "$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe Redakteur $s" \ + '{post_id: $id, tree_rev: $rev, id: $s, title: $t}')")" "library-save $s" "$p" + refused "$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "${PROBE_ENTRIES[$i]}" \ + '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")" "library-insert copy $s" "$p" + refused "$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PROBE_ID" --arg s "$s" \ + --arg srev "$PROBE_REV" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")" "node-copy $s" "$p" + i=$((i + 1)) +done +[ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "a refused probe wrote to the target page" +[ "$(wp post list --post_type=et_pb_layout --post_status=any --s="$PREFIX Probe Redakteur" --format=count)" = "0" ] \ + || fail "a refused library-save created an entry" +if content_of "$TARGET_ID" | grep -q 'data-probe'; then fail "a probe marker reached the target page"; fi +pass "editor: interactions, Lottie source and custom HTML refused by library-save, library-insert copy and node-copy" + +# 11. Group "Divi-Bibliothek" disabled: library IDs not_found in the Divi read tools, library tools gone +connect "$ADMIN_LOGIN" +[ "$(error_code "$(outline "$LIB_ID")")" = "" ] || fail "library entry not readable before disabling the group" +wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null +connect "$ADMIN_LOGIN" +LIST=$(rpc tools/list '{}') +if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then fail "lit-library-insert listed with the group disabled"; fi +for a in lit/library-list lit/library-save lit/library-insert; do + GONE=$(ability "$a" '{}') + echo "$GONE" | jq -e 'has("__error")' >/dev/null && echo "$GONE" | grep -q 'not found' || fail "$a still executable: $GONE" +done +for id in "$LIB_ID" "$GLOB_ID"; do + [ "$(error_code "$(outline "$id")")" = "not_found" ] || fail "outline of library entry $id with the group disabled" + [ "$(error_code "$(tool lit-divi-node-get "{\"post_id\":$id,\"id\":\"s0\"}")")" = "not_found" ] || fail "node-get of $id with the group disabled" +done +[ "$(error_code "$(outline "$TARGET_ID")")" = "" ] || fail "pages must stay readable with the library group disabled" +restore_exposure +connect "$ADMIN_LOGIN" +rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "exposure not restored" +pass "group Divi-Bibliothek disabled: library IDs not_found in outline/node-get, library tools gone; option restored" + +finish From 1b42f9b64074c9c8fab2276d541776279cf5865e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:06:56 +0200 Subject: [PATCH 28/47] fix: lit/divi-node-copy from a library entry needs the insert rights (I-1, L5) A library entry as source now requires LibraryAccess::canInsert() (add_library, writable library group) and follows lit-library-insert's Tools-tab state (disabled -> unsupported). DiviPageLoader::library() gets its caller. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 7 ++- src/Integrations/Divi/DiviIntegration.php | 7 ++- .../Divi/Pages/DiviNodeCopyAbility.php | 37 ++++++++++++-- src/Plugin.php | 9 +++- .../Divi/Pages/DiviNodeCopyAbilityTest.php | 49 ++++++++++++++++++- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 09663dd..a202598 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -29,7 +29,9 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv - `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle – Pflicht, weil Pfad-IDs nur für diesen Stand gelten; Ziel mit `post_id`, `tree_rev`, Position). Quelle und Ziel dürfen - Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. + Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Aus + einem Bibliothekseintrag zu kopieren ist Einfügen aus der Bibliothek: es braucht dieselben Rechte und + Freigaben wie `lit-library-insert`. - Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit/divi-duplicate-node` und `lit/divi-replace-text` mit der ID des Eintrags als `post_id`. @@ -79,7 +81,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. - `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist. + `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist; + `lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle auch, wenn `lit-library-insert` gesperrt ist. - Ohne das Recht `unfiltered_html` überträgt LIT MCP beim Kopieren, Einfügen und Speichern (`lit/divi-node-copy`, `lit-library-insert`, `lit/library-save`) nur Knoten, die das Konto auch selbst schreiben dürfte. Abgelehnt werden – „nur im Divi-Builder“ – Knoten mit eigenem HTML, CSS oder HTML-Attributen, Interaktionen, einer diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index d94676e..1ff9cbb 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -58,13 +58,16 @@ final class DiviIntegration implements Integration * "Divi-Bibliothek" not disabled, ruling P9); null = always. * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy * follows it (ruling P9, E9); null = always. + * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled – + * lit/divi-node-copy follows it for library entries as source (audit L5); null = always. */ public function __construct( private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null, ?Closure $libraryVisible = null, - private ?Closure $diviWritable = null + private ?Closure $diviWritable = null, + private ?Closure $libraryInsertable = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -119,7 +122,7 @@ final class DiviIntegration implements Integration new DiviDeleteAbility($writer), new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), - new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), + new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), ]; } diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php index cc6e8a7..148dddc 100644 --- a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -19,15 +19,22 @@ defined('ABSPATH') || exit; * hiding) and its own source_tree_rev, because path IDs are only valid for the state they were read * in; the source is only read, so it needs no lock. Target: like lit-divi-insert (tree_rev lock, * StructureRules, all or nothing, DiviWriter::commit with GlobalEdits – a global target entry never - * gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). + * gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). A library entry + * as source is an insert from the library (E3, audit L5): it needs LibraryAccess::canInsert() + * (add_library, writable group "Divi-Bibliothek") and follows lit-library-insert's Tools-tab state. */ final class DiviNodeCopyAbility extends DiviWriteAbility { - /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */ + /** + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. + * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled (gates + * library entries as source); null = always. + */ public function __construct( DiviWriter $writer, private DiviPageLoader $loader, - private ?Closure $diviWritable = null + private ?Closure $diviWritable = null, + private ?Closure $libraryInsertable = null ) { parent::__construct($writer); } @@ -74,7 +81,10 @@ final class DiviNodeCopyAbility extends DiviWriteAbility return false; } - /** lit/divi-insert's Tools-tab state, then the source's own rights – in permission() and execute(). */ + /** + * lit/divi-insert's Tools-tab state, then the source's own rights and, for a library entry as + * source, the rights of an insert from the library – in permission() and execute(). + */ protected function precheck(array $input): ?WP_Error { if ($this->diviWritable !== null && ! ($this->diviWritable)()) { @@ -86,8 +96,25 @@ final class DiviNodeCopyAbility extends DiviWriteAbility } $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } - return $source instanceof WP_Error ? $source : null; + return $source->isLibraryItem() ? $this->libraryInsertRefusal() : null; + } + + /** E3/audit L5: copying out of a library entry is inserting from the library (lit-library-insert). */ + private function libraryInsertRefusal(): ?WP_Error + { + if ($this->libraryInsertable !== null && ! ($this->libraryInsertable)()) { + return Errors::unsupported( + 'Einfügen aus der Divi-Bibliothek ist für diese Verbindung abgeschaltet (lit-library-insert ' + . 'deaktiviert oder Gruppe „Divi-Bibliothek“ auf „nur lesen“).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return $this->loader->library()?->canInsert(); } protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error diff --git a/src/Plugin.php b/src/Plugin.php index f831cc5..4bfc0dd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -89,12 +89,19 @@ final class Plugin $libraryWritable = static function () use (&$policy): bool { return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); }; + // Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state. + $libraryInsertable = static function () use (&$registry, &$policy): bool { + return $registry instanceof IntegrationRegistry + && $policy instanceof ExposurePolicy + && $policy->allows('lit/library-insert', $registry->abilities()); + }; $divi = new DiviIntegration( $schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible, - diviWritable: $diviWritable + diviWritable: $diviWritable, + libraryInsertable: $libraryInsertable ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php index c07980b..20b41cc 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -42,7 +42,8 @@ final class DiviNodeCopyAbilityTest extends TestCase private function ability( bool $diviWritable = true, bool $unfiltered = false, - ?LibraryAccess $access = null + ?LibraryAccess $access = null, + bool $libraryInsert = true ): DiviNodeCopyAbility { $access ??= LibraryFixtures::access(); $loader = LibraryFixtures::loader($access); @@ -54,7 +55,8 @@ final class DiviNodeCopyAbilityTest extends TestCase return new DiviNodeCopyAbility( DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), $loader, - static fn (): bool => $diviWritable + static fn (): bool => $diviWritable, + static fn (): bool => $libraryInsert ); } @@ -206,6 +208,49 @@ final class DiviNodeCopyAbilityTest extends TestCase self::assertStringContainsString('Library-Blurb', $this->saved[0]); } + /** @return array */ + private function librarySource(): array + { + return $this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), + 'source_id' => 's0', + ]); + } + + public function testALibrarySourceNeedsAddLibrary(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library'])); + + self::assertToolError($ability->permission($this->librarySource()), 'forbidden'); + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceNeedsAWritableLibraryGroup(): void + { + $ability = $this->ability(access: LibraryFixtures::access(writable: false)); + + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceFollowsLibraryInsertExposure(): void + { + $ability = $this->ability(libraryInsert: false); + + self::assertToolError($ability->permission($this->librarySource()), 'unsupported'); + self::assertToolError($ability->execute($this->librarySource()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testPageSourcesNeedNeitherAddLibraryNorLibraryInsert(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library']), libraryInsert: false); + + self::assertTrue($ability->execute($this->input())['ok']); + } + public function testAHiddenLibraryIsNoSource(): void { $item = LibraryFixtures::item(1055); From bd28c32513c2d0c937d5e19254f8f226ce6dcc58 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:07:24 +0200 Subject: [PATCH 29/47] docs: state the P13 limit honestly in the library guide (I-2) With the library group disabled the entries stay unreadable and unwritable, but page outline, node-get and render summary still name global module IDs; full hiding follows in 3b. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 4 ++++ .../notes/2026-09-27-etappe-3-entscheidungen-entwurf.md | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index a202598..35fa486 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -81,6 +81,10 @@ 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. `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`, diff --git a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md index f13c78d..9ff14d8 100644 --- a/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md +++ b/docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md @@ -191,8 +191,9 @@ Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantt - E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (`ExposurePolicy::groupWritable`). `lit-library-insert` und `lit/divi-node-copy` folgen - zusätzlich dem Zustand von `lit/divi-insert` (M2). Offen (P13, Schlusswelle): bei deaktivierter Gruppe - nennen Outline/node-get/Render-Auszug einer Seite noch die ID globaler Module. + zusätzlich dem Zustand von `lit/divi-insert` (M2). P13 in 3a nur dokumentiert (Guide „Rechte“, Grenze): bei + deaktivierter Gruppe oder ohne `divi_library` nennen Outline/node-get/Render-Auszug einer Seite noch die ID + globaler Module; der Eintrag selbst bleibt `not_found`. Vollständiges Ausblenden folgt in 3b. - Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die betroffenen Seiten. P8a: die Bestätigung wird abgelehnt, wenn der Benutzer einen Eintrag mit Status From e8f69f67ea026674644eadd05a5716779ed70d70 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:12:38 +0200 Subject: [PATCH 30/47] fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlobalUsages runs one query per request (ID, status, type – no content), checks read_post/edit_post in chunks of 200 with a primed and released post cache, lists at most 50 readable posts and refuses a confirmed global edit above 2000 live references (unsupported, edit in the Divi builder). Posts the caller may not read appear only as hidden_count / blocked_hidden_count; blocked_ids names readable posts only. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 8 +- src/Integrations/Divi/Library/GlobalEdits.php | 129 ++++++++------- .../Divi/Library/GlobalUsages.php | 152 +++++++++++++----- .../Divi/Library/LibraryListAbility.php | 5 +- src/Integrations/Divi/Library/UsageReport.php | 61 +++++++ .../Divi/Pages/DiviReplaceTextAbility.php | 2 +- .../Divi/Pages/DiviWriteAbility.php | 1 + tests/Support/LibraryFixtures.php | 24 +++ .../Divi/Library/GlobalEditsTest.php | 80 +++++++-- .../Divi/Library/GlobalUsagesTest.php | 137 ++++++++++++---- .../Divi/Library/LibraryListAbilityTest.php | 8 +- .../Divi/Pages/DiviNodeCopyAbilityTest.php | 5 +- 12 files changed, 445 insertions(+), 167 deletions(-) create mode 100644 src/Integrations/Divi/Library/UsageReport.php diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 35fa486..cec9b81 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -43,8 +43,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. 2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. 3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf: Antwort `confirm_required` mit - `affected` (betroffene Seiten; Titel nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; - gespeichert wird nichts. + `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das + Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts. 4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). 5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine @@ -53,7 +53,9 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv 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` (Entwürfe blockieren nicht). Sind Revisionen für die Bibliothek abgeschaltet, lehnt das +`blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht). 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). diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index 20b1739..a3eee20 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -28,7 +28,9 @@ defined('ABSPATH') || exit; * copy is made first – the revision itself is DiviSaver's restore point, as for every page. * Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may * not edit_post one of the published, private or scheduled posts that show the entry (rulings - * P8/P8a, stricter than Divi); the dry run names them already (blocked_ids). + * 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). */ @@ -89,27 +91,26 @@ final class GlobalEdits return ['scope' => SaveScope::LibraryItem, 'details' => []]; } - $usages = $this->usages->of($id); - $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']]; - $blocked = $this->blocked($id); + $report = $this->usages->report($id); if (($input['confirm'] ?? false) !== true) { return Errors::confirmRequired( sprintf( 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', $id, - $usages['count'], - self::pages($usages['count']) - ) . self::blockedNote($blocked), - $details + self::blockedDetails($blocked) + $report->count, + self::pages($report->count) + ) . self::hiddenNote($report) . self::blockedNote($report), + $report->details() ); } - $refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item); + $refusal = $this->pagesRefusal($id, $report) ?? $this->backupRefusal($item); if ($refusal !== null) { return $refusal; } + $details = $report->listing(); if (! wp_revisions_enabled($item)) { $details['backup_id'] = ($this->backup)($item); } @@ -127,9 +128,7 @@ final class GlobalEdits return $error; } - $id = (int) $page->post->ID; - $usages = $this->usages->of($id); - $blocked = $this->blocked($id); + $report = $this->usages->report((int) $page->post->ID); $data = $error->get_error_data(); $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; @@ -137,79 +136,93 @@ final class GlobalEdits Errors::clientMessage($error) . sprintf( ' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).', - $usages['count'], - self::pages($usages['count']) + $report->count, + self::pages($report->count) ) - . self::blockedNote($blocked), - array_merge( - $details, - ['affected' => $usages['items'], 'affected_count' => $usages['count']], - self::blockedDetails($blocked) - ) + . self::hiddenNote($report) + . self::blockedNote($report), + array_merge($details, $report->details()) ); } /** - * Ruling P8a: the live posts (published, private, scheduled – they render now or will) that show - * entry $id and that the user may not edit_post. Drafts and pending posts never block. - * - * @return list + * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above + * GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1). */ - private function blocked(int $id): array + private function pagesRefusal(int $id, UsageReport $report): ?WP_Error { - $live = $this->usages->liveIds($id); - if ($live === []) { - return []; + if ($report->tooMany) { + return Errors::unsupported( + sprintf( + 'Globaler Bibliothekseintrag %d erscheint auf %d veröffentlichten, privaten oder geplanten ' + . 'Einträgen – mehr als %d; so große Änderungen nimmt LIT MCP nicht vor. Nichts wurde ' + . 'gespeichert.', + $id, + $report->live, + GlobalUsages::HARD_CAP + ), + 'Den Eintrag im Divi-Builder bearbeiten.' + ); } - _prime_post_caches($live, false, false); - - return array_values(array_filter( - $live, - static fn (int $postId): bool => ! current_user_can('edit_post', $postId) - )); - } - - /** - * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id. - * - * @param list $blocked - */ - private function pagesRefusal(int $id, array $blocked): ?WP_Error - { - if ($blocked === []) { + if (! $report->blocks()) { return null; } return Errors::forbidden( sprintf( 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, ' - . 'die dieses Konto nicht bearbeiten darf (IDs %s); nichts wurde gespeichert.', + . 'die dieses Konto nicht bearbeiten darf (%s); nichts wurde gespeichert.', $id, - self::listed($blocked) + self::blockedList($report) ), 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' . 'oder Administrator).' ); } - /** @param list $blocked */ - private static function blockedNote(array $blocked): string + /** Audit L4: posts the caller may not read appear only as a count. */ + private static function hiddenNote(UsageReport $report): string { - return $blocked === [] ? '' : sprintf( - ' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' - . 'Konto nicht bearbeiten darf, zeigen den Eintrag (IDs %s, siehe blocked_ids).', - self::listed($blocked) + return $report->hidden === 0 ? '' : sprintf( + ' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).', + $report->hidden ); } - /** - * @param list $blocked - * @return array> - */ - private static function blockedDetails(array $blocked): array + private static function blockedNote(UsageReport $report): string { - return $blocked === [] ? [] : ['blocked_ids' => $blocked]; + if ($report->tooMany) { + return sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante ' + . 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.', + GlobalUsages::HARD_CAP + ); + } + + return ! $report->blocks() ? '' : sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' + . 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).', + self::blockedList($report) + ); + } + + /** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */ + private static function blockedList(UsageReport $report): string + { + $parts = []; + if ($report->blocked !== []) { + $parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids'; + } + if ($report->blockedHidden > 0) { + $parts[] = sprintf( + '%d %s, die dieses Konto nicht lesen darf', + $report->blockedHidden, + $report->blockedHidden === 1 ? 'Eintrag' : 'Einträge' + ); + } + + return implode('; ', $parts); } /** @param list $ids At most LISTED_IDS of them, then "und N weitere". */ diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 12342ff..7f385ae 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -4,87 +4,151 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use WP_Post; + defined('ABSPATH') || exit; /** - * Pages (and Theme Builder layouts) that show global library entry $id: every living post whose - * content has "globalModule":"" – Divi's own way to find instances (string scan, - * BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it. + * Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every + * living post whose content has "globalModule":"" – Divi's own way to find instances (string + * scan, BlockParserStore, F10). One query per report (ID, status and type only – never the content, + * audit L1); the rights of the caller are then checked in chunks with a bounded post cache: + * - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4); + * - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when + * readable, else as a count; above HARD_CAP live posts the edit is refused as a whole. + * Not final so tests can mock rows(). */ class GlobalUsages { + /** At most this many readable posts are listed. */ public const LIMIT = 50; + /** Above this many live posts a confirmed edit is refused without checking each of them (audit L1). */ + public const HARD_CAP = 2000; + + /** At most this many posts are checked for read_post (the listing and hidden_count). */ + public const CHECK_CAP = 2000; + + /** Posts whose rights are checked with one primed cache. */ + public const CHUNK = 200; + + /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ + public const LIVE = ['publish', 'private', 'future']; + /** Every post that exists for the user: listed in the dry run. */ private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')"; - /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ - private const LIVE = "post_status IN ('publish', 'private', 'future')"; - public function __construct(private ?\wpdb $db = null) { } - /** @return array{items: list, count: int} */ - public function of(int $libraryId): array + /** + * The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post + * on every live post) – the dry run and the confirm of an edit need it, a listing does not. + */ + public function report(int $libraryId, bool $withBlocked = true): UsageReport + { + $rows = $this->rows($libraryId); + $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); + $tooMany = $withBlocked && $live > self::HARD_CAP; + $checkEdit = $withBlocked && ! $tooMany; + + $items = []; + $hidden = 0; + $blocked = []; + $blockedHidden = 0; + $checked = 0; + foreach (array_chunk($rows, self::CHUNK) as $chunk) { + $todo = array_values(array_filter( + $chunk, + static function (array $row) use (&$checked, $checkEdit): bool { + $isLive = in_array($row['status'], self::LIVE, true); + if ($checked < self::CHECK_CAP || ($checkEdit && $isLive)) { + ++$checked; + + return true; + } + + return false; + } + )); + if ($todo === []) { + continue; + } + + $ids = array_map(static fn (array $row): int => $row['id'], $todo); + $fresh = _get_non_cached_ids($ids, 'posts'); + _prime_post_caches($ids, false, false); + foreach ($todo as $row) { + $id = $row['id']; + $readable = current_user_can('read_post', $id); + if ($readable && count($items) < self::LIMIT) { + $post = get_post($id); + $items[] = [ + 'post_id' => $id, + 'title' => $post instanceof WP_Post ? (string) $post->post_title : '', + 'status' => $row['status'], + 'type' => $row['type'], + ]; + } elseif (! $readable) { + ++$hidden; + } + + if ($checkEdit && in_array($row['status'], self::LIVE, true) && ! $this->editable($row)) { + if ($readable) { + $blocked[] = $id; + } else { + ++$blockedHidden; + } + } + } + foreach ($fresh as $id) { + wp_cache_delete((int) $id, 'posts'); + } + } + + return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany); + } + + /** + * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. + * + * @return list + */ + public function rows(int $libraryId): array { $db = $this->db(); - $like = self::like($db, $libraryId); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery - $count = (int) $db->get_var( - $db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::where(self::LISTED), $like) - ); $rows = $db->get_results( $db->prepare( - 'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' - . self::where(self::LISTED) . ' ORDER BY ID ASC LIMIT %d', - $like, - self::LIMIT + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' + . "post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', + self::like($db, $libraryId) ), 'ARRAY_A' ); // phpcs:enable - $items = []; + $result = []; foreach (is_array($rows) ? $rows : [] as $row) { - $id = (int) $row['ID']; - $items[] = [ - 'post_id' => $id, - 'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)', + $result[] = [ + 'id' => (int) $row['ID'], 'status' => (string) $row['post_status'], 'type' => (string) $row['post_type'], ]; } - return ['items' => $items, 'count' => max($count, count($items))]; + return $result; } /** - * IDs of every live post (published, private, scheduled) that shows $libraryId – no limit - * (ruling P8a checks edit_post on each of them before a confirmed global edit). + * Ruling P8a: whether the caller may edit_post the live post $row. * - * @return list + * @param array{id: int, status: string, type: string} $row */ - public function liveIds(int $libraryId): array + protected function editable(array $row): bool { - $db = $this->db(); - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery - $ids = $db->get_col($db->prepare( - 'SELECT ID FROM ' . $db->posts . ' WHERE ' . self::where(self::LIVE) . ' ORDER BY ID ASC', - self::like($db, $libraryId) - )); - // phpcs:enable - - return array_values(array_map('intval', is_array($ids) ? $ids : [])); - } - - /** - * The one condition of both queries: posts (no revisions) whose content holds an instance - * (placeholder %s = like()), with the fixed status clause $status (a class constant). - */ - private static function where(string $status): string - { - return "post_content LIKE %s AND post_type <> 'revision' AND " . $status; + return current_user_can('edit_post', $row['id']); } private static function like(\wpdb $db, int $libraryId): string diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index b503d27..85a0c3b 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -103,6 +103,7 @@ final class LibraryListAbility implements Ability 'edit_url' => ['type' => 'string'], 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], ], 'required' => ['items', 'next_cursor'], ]; @@ -232,9 +233,7 @@ final class LibraryListAbility implements Ability 'edit_url' => $page->editUrl(), ]; if ($item['scope'] === 'global') { - $usages = $this->usages->of((int) $page->post->ID); - $result['affected'] = $usages['items']; - $result['affected_count'] = $usages['count']; + $result += $this->usages->report((int) $page->post->ID, false)->listing(); } return $result; diff --git a/src/Integrations/Divi/Library/UsageReport.php b/src/Integrations/Divi/Library/UsageReport.php new file mode 100644 index 0000000..d04e6b6 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageReport.php @@ -0,0 +1,61 @@ + $items Readable + * posts (at most GlobalUsages::LIMIT). + * @param int $hidden Posts the caller may not read. + * @param int $live Published, private and scheduled posts among them (ruling P8a). + * @param list $blocked Readable live posts the caller may not edit. + * @param int $blockedHidden Unreadable live posts the caller may not edit. + * @param bool $tooMany More live posts than GlobalUsages::HARD_CAP – not checked one by one (audit L1). + */ + public function __construct( + public readonly int $count, + public readonly array $items, + public readonly int $hidden, + public readonly int $live, + public readonly array $blocked, + public readonly int $blockedHidden, + public readonly bool $tooMany + ) { + } + + public function exists(): bool + { + return $this->count > 0; + } + + /** Whether a confirmed edit would be refused (ruling P8a, audit L1). */ + public function blocks(): bool + { + return $this->tooMany || $this->blocked !== [] || $this->blockedHidden > 0; + } + + /** @return array affected, affected_count and, when there are any, hidden_count. */ + public function listing(): array + { + return ['affected' => $this->items, 'affected_count' => $this->count] + + ($this->hidden > 0 ? ['hidden_count' => $this->hidden] : []); + } + + /** @return array listing() plus blocked_ids and blocked_hidden_count when there are any. */ + public function details(): array + { + return $this->listing() + + ($this->blocked !== [] ? ['blocked_ids' => $this->blocked] : []) + + ($this->blockedHidden > 0 ? ['blocked_hidden_count' => $this->blockedHidden] : []); + } +} diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 7a8ce54..7b3e433 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -404,7 +404,7 @@ final class DiviReplaceTextAbility implements Ability . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', 'affected' => $data['affected'] ?? [], 'affected_count' => $data['affected_count'] ?? 0, - ]; + ] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []); } /** diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index fac7ac2..3bddc36 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -39,6 +39,7 @@ abstract class DiviWriteAbility implements Ability // made when revisions are off. 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], 'backup_id' => ['type' => 'integer'], ], ['tree_rev']); } diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php index df9fd56..83742fb 100644 --- a/tests/Support/LibraryFixtures.php +++ b/tests/Support/LibraryFixtures.php @@ -8,6 +8,7 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use Mockery; @@ -111,6 +112,29 @@ final class LibraryFixtures return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); } + /** + * GlobalUsages whose query answers $rows (a closure is asked on every call); the rights checks + * are the real ones, with the post-cache functions stubbed. + * + * @param list|\Closure(): list $rows + */ + public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages + { + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class, [null, $roles])->makePartial(); + $usages->allows('rows')->andReturnUsing(static fn (): array => is_array($rows) ? $rows : $rows()); + + return $usages; + } + + /** @return list One row per ID. */ + public static function rows(array $ids, string $status = 'publish', string $type = 'page'): array + { + return array_map(static fn (int $id): array => ['id' => $id, 'status' => $status, 'type' => $type], $ids); + } + public static function loader(?LibraryAccess $access = null): DiviPageLoader { return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php index cf171d2..2d2ba12 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -50,19 +50,16 @@ final class GlobalEditsTest extends TestCase Functions\when('_prime_post_caches')->justReturn(null); } + /** The live posts ($this->live; 1053 published, the others private) and draft 1070. */ private function usages(): GlobalUsages { - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn([ - 'items' => [ - ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], - ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], - ], - 'count' => 2, - ]); - $usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live); - - return $usages; + return LibraryFixtures::usages(fn (): array => array_merge( + array_map( + static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'], + $this->live + ), + [['id' => 1070, 'status' => 'draft', 'type' => 'page']] + )); } /** @param list $deniedRoles */ @@ -274,6 +271,60 @@ final class GlobalEditsTest extends TestCase self::assertStringContainsString('abgelehnt', $dryRun->get_error_message()); } + /** Audit L4: posts the caller may not read appear only as counts – no ID, status or type. */ + public function testUnreadablePostsAppearOnlyAsCounts(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $this->live = [1053, 1081]; + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! (in_array($cap, ['read_post', 'edit_post'], true) + && in_array((int) ($args[0] ?? 0), [1070, 1081], true)) + ); + + $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059)); + $refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true)); + + self::assertToolError($dryRun, 'confirm_required'); + $data = $dryRun->get_error_data(); + self::assertSame([1053], array_column($data['affected'], 'post_id')); + self::assertSame(3, $data['affected_count']); + self::assertSame(2, $data['hidden_count']); + self::assertSame(1, $data['blocked_hidden_count']); + self::assertArrayNotHasKey('blocked_ids', $data); + self::assertStringNotContainsString('1081', $dryRun->get_error_message()); + self::assertStringNotContainsString('1070', $dryRun->get_error_message()); + self::assertStringNotContainsString('1081', (string) wp_json_encode($data)); + self::assertToolError($refused, 'forbidden'); + self::assertStringNotContainsString('1081', $refused->get_error_message()); + self::assertStringContainsString('nicht lesen darf', $refused->get_error_message()); + self::assertSame([], $this->saved); + } + + /** Audit L1: above the hard cap a confirmed global edit is refused without checking each post. */ + public function testAboveTheHardCapTheConfirmIsRefused(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $this->live = range(20001, 20001 + GlobalUsages::HARD_CAP); + $edits = 0; + Functions\when('current_user_can')->alias(static function (string $cap, mixed ...$args) use (&$edits): bool { + $edits += $cap === 'edit_post' && ($args[0] ?? 0) !== 1059 ? 1 : 0; + + return true; + }); + + $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059)); + $refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true)); + + self::assertToolError($dryRun, 'confirm_required'); + self::assertStringContainsString('mehr als 2000', $dryRun->get_error_message()); + self::assertCount(GlobalUsages::LIMIT, $dryRun->get_error_data()['affected']); + self::assertSame(GlobalUsages::HARD_CAP + 2, $dryRun->get_error_data()['affected_count']); + self::assertToolError($refused, 'unsupported'); + self::assertStringContainsString('Divi-Builder', $refused->get_error_data()['hint']); + self::assertSame(0, $edits, 'no edit_post check per post above the cap'); + self::assertSame([], $this->saved); + } + public function testADryRunWithoutBlockersHasNoBlockedIds(): void { LibraryFixtures::given([LibraryFixtures::item(1059)]); @@ -329,12 +380,7 @@ final class GlobalEditsTest extends TestCase public function testDeleteNoticeUsesTheSingularForOnePage(): void { LibraryFixtures::given([LibraryFixtures::item(1058)]); - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn([ - 'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']], - 'count' => 1, - ]); - $usages->allows('liveIds')->andReturn([1053]); + $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); $access = LibraryFixtures::access(); $design = DiviWriterFixture::designReader(); $writer = new DiviWriter( diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 0436fb8..5e17674 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -6,60 +6,133 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WP_Post; final class GlobalUsagesTest extends TestCase { - public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void + /** Audit L1: one query, ID, status and type only – never the content, no COUNT, no second scan. */ + public function testFindsInstancesLikeDiviWithOneQueryWithoutContent(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $queries = []; - $db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string { - $queries[] = $sql; - - return '3'; - }); $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { $queries[] = $sql; return [ - ['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'], - ['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'], + ['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'], + ['ID' => '1061', 'post_status' => 'private', 'post_type' => 'et_header_layout'], ]; }); - Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061); - $usages = (new GlobalUsages($db))->of(1058); + $rows = (new GlobalUsages($db))->rows(1058); - self::assertSame(3, $usages['count']); self::assertSame( [ - ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'], - ['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'], + ['id' => 1053, 'status' => 'draft', 'type' => 'page'], + ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], ], - $usages['items'] + $rows ); + self::assertCount(1, $queries); + self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); - self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]); - self::assertStringContainsString('LIMIT 50', $queries[1]); - } - - /** Ruling P8a: every live (published, private, scheduled) post showing the entry, beyond the listing limit. */ - public function testLiveIdsAreUnlimited(): void - { - $db = Mockery::mock(\wpdb::class)->makePartial(); - $queries = []; - $db->allows('get_col')->andReturnUsing(static function (string $sql) use (&$queries): array { - $queries[] = $sql; - - return ['1053', '1080']; - }); - - self::assertSame([1053, 1080], (new GlobalUsages($db))->liveIds(1059)); - self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]); - self::assertStringContainsString("post_status IN ('publish', 'private', 'future')", $queries[0]); + self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]); + self::assertStringNotContainsString('post_content,', $queries[0]); + } + + /** Audit L4: readable posts with title, unreadable ones only counted. */ + public function testListsReadablePostsAndCountsTheOthers(): void + { + $usages = LibraryFixtures::usages([ + ['id' => 1053, 'status' => 'draft', 'type' => 'page'], + ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], + ]); + Functions\when('get_post')->alias( + static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_title' => 'Titel ' . $id]) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $id !== 1061 + ); + + $report = $usages->report(1058); + + self::assertSame(2, $report->count); + self::assertSame( + [['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'draft', 'type' => 'page']], + $report->items + ); + self::assertSame(1, $report->hidden); + self::assertSame(1, $report->live); + self::assertSame([], $report->blocked); + self::assertSame(1, $report->blockedHidden); + self::assertSame( + ['affected' => $report->items, 'affected_count' => 2, 'hidden_count' => 1, 'blocked_hidden_count' => 1], + $report->details() + ); + } + + /** Audit L1: rights are checked in chunks, each with its own primed cache that is dropped afterwards. */ + public function testChecksLargeSetsInChunksAndReleasesTheCache(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, 450))); + $primed = []; + Functions\when('_prime_post_caches')->alias(static function (array $ids) use (&$primed): void { + $primed[] = count($ids); + }); + $deleted = 0; + Functions\when('wp_cache_delete')->alias(static function () use (&$deleted): bool { + ++$deleted; + + return true; + }); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id % 100 !== 0); + + $report = $usages->report(1058); + + self::assertSame([GlobalUsages::CHUNK, GlobalUsages::CHUNK, 50], $primed); + self::assertSame(450, $deleted); + self::assertSame(450, $report->count); + self::assertCount(GlobalUsages::LIMIT, $report->items); + self::assertSame(4, $report->hidden); + self::assertSame(4, $report->blockedHidden); + self::assertTrue($report->blocks()); + } + + /** Audit L1: above the hard cap nothing is checked for edit_post, and the report blocks. */ + public function testAboveTheHardCapNoPostIsCheckedForEditing(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, GlobalUsages::HARD_CAP + 1))); + Functions\when('get_post')->justReturn(null); + $asked = []; + Functions\when('current_user_can')->alias(static function (string $cap) use (&$asked): bool { + $asked[$cap] = ($asked[$cap] ?? 0) + 1; + + return true; + }); + + $report = $usages->report(1058); + + self::assertTrue($report->tooMany); + self::assertTrue($report->blocks()); + self::assertArrayNotHasKey('edit_post', $asked); + self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']); + } + + public function testAListingSkipsTheEditChecks(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post'); + + $report = $usages->report(1058, false); + + self::assertFalse($report->blocks()); + self::assertSame(['affected', 'affected_count'], array_keys($report->listing())); } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index 8be91ab..d852cd7 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -37,11 +37,9 @@ final class LibraryListAbilityTest extends TestCase $limit + 1 )); $access = LibraryFixtures::access($deniedRoles, true, $store); - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->with(1058)->andReturn([ - 'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']], - 'count' => 1, - ]); + $usages = LibraryFixtures::usages( + static fn (): array => [['id' => 1053, 'status' => 'draft', 'type' => 'page']] + ); return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php index 20b41cc..21c1aaa 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -10,7 +10,6 @@ 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\Library\GlobalEdits; -use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Tests\Support\DiviFixtures; @@ -48,9 +47,7 @@ final class DiviNodeCopyAbilityTest extends TestCase $access ??= LibraryFixtures::access(); $loader = LibraryFixtures::loader($access); $failures = []; - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn(['items' => [], 'count' => 0]); - $globals = new GlobalEdits($access, $usages); + $globals = new GlobalEdits($access, LibraryFixtures::usages([])); return new DiviNodeCopyAbility( DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), From 8aacc5dc4fb4cef3808bbaa9f090405431bafdbf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:13:14 +0200 Subject: [PATCH 31/47] fix: P8a blocks Theme Builder layouts without Divi's theme_builder role (L6) et_header_layout, et_body_layout, et_footer_layout and et_template showing a global entry now block a confirmed edit unless et_pb_is_allowed('theme_builder'), like Divi's own check for TB layouts. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 4 +++- src/Integrations/Divi/DiviIntegration.php | 2 +- src/Integrations/Divi/Library/DiviRoles.php | 1 + .../Divi/Library/GlobalUsages.php | 21 +++++++++++++++---- .../Divi/Library/LibraryIntegration.php | 7 ++++++- tests/Support/LibraryFixtures.php | 2 +- .../Divi/Library/GlobalUsagesTest.php | 20 ++++++++++++++++++ 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index cec9b81..ba3c121 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -53,7 +53,9 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv 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). Zeigen +`blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht). +Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn die Divi-Rolle des Kontos +„Theme Builder“ nicht erlaubt. Zeigen mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab (`unsupported`); solche Einträge im Divi-Builder bearbeiten. Sind Revisionen für die Bibliothek abgeschaltet, lehnt das Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 1ff9cbb..2702d5f 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -155,7 +155,7 @@ final class DiviIntegration implements Integration $this->log, $this->design, new DiviComposition($this->modules, $this->design), - new GlobalEdits($this->access, new GlobalUsages()) + new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())) ); } diff --git a/src/Integrations/Divi/Library/DiviRoles.php b/src/Integrations/Divi/Library/DiviRoles.php index 1adc241..a28c9f0 100644 --- a/src/Integrations/Divi/Library/DiviRoles.php +++ b/src/Integrations/Divi/Library/DiviRoles.php @@ -25,6 +25,7 @@ class DiviRoles 'add_library' => 'Aus der Bibliothek hinzufügen', 'edit_global_library' => 'Globale Bibliothekselemente bearbeiten', 'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen', + 'theme_builder' => 'Theme Builder', ]; /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 7f385ae..3481c37 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -15,7 +15,8 @@ defined('ABSPATH') || exit; * audit L1); the rights of the caller are then checked in chunks with a bounded post cache: * - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4); * - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when - * readable, else as a count; above HARD_CAP live posts the edit is refused as a whole. + * readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option + * (audit L6); above HARD_CAP live posts the edit is refused as a whole. * Not final so tests can mock rows(). */ class GlobalUsages @@ -38,8 +39,14 @@ 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')"; - public function __construct(private ?\wpdb $db = null) + /** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */ + public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template']; + + private DiviRoles $roles; + + public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null) { + $this->roles = $roles ?? new DiviRoles(); } /** @@ -142,12 +149,18 @@ class GlobalUsages } /** - * Ruling P8a: whether the caller may edit_post the live post $row. + * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or + * template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own + * et_pb_is_allowed check for TB layouts, builder/functions.php). * * @param array{id: int, status: string, type: string} $row */ - protected function editable(array $row): bool + private function editable(array $row): bool { + if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) { + return false; + } + return current_user_can('edit_post', $row['id']); } diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index c32fd32..1b2d750 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -54,7 +54,12 @@ final class LibraryIntegration implements Integration public function abilities(): array { return [ - new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()), + new LibraryListAbility( + $this->access, + $this->loader, + $this->design, + new GlobalUsages(null, $this->access->roles()) + ), new LibrarySaveAbility( $this->access, $this->loader, diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php index 83742fb..19b204e 100644 --- a/tests/Support/LibraryFixtures.php +++ b/tests/Support/LibraryFixtures.php @@ -123,7 +123,7 @@ final class LibraryFixtures 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])->makePartial(); + $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial(); $usages->allows('rows')->andReturnUsing(static fn (): array => is_array($rows) ? $rows : $rows()); return $usages; diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 5e17674..a682cd9 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -124,6 +124,26 @@ final class GlobalUsagesTest extends TestCase self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']); } + /** Audit L6: Theme Builder layouts additionally need Divi's role option theme_builder. */ + public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void + { + $rows = [ + ['id' => 1053, 'status' => 'publish', 'type' => 'page'], + ['id' => 1061, 'status' => 'publish', 'type' => 'et_header_layout'], + ['id' => 1062, 'status' => 'publish', 'type' => 'et_body_layout'], + ['id' => 1063, 'status' => 'publish', 'type' => 'et_footer_layout'], + ['id' => 1064, 'status' => 'publish', 'type' => 'et_template'], + ]; + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->justReturn(true); + + $without = LibraryFixtures::usages($rows, LibraryFixtures::roles(['theme_builder']))->report(1058); + $with = LibraryFixtures::usages($rows, LibraryFixtures::roles())->report(1058); + + self::assertSame([1061, 1062, 1063, 1064], $without->blocked); + self::assertSame([], $with->blocked); + } + public function testAListingSkipsTheEditChecks(): void { $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); From 395049ec976f71f42192b84815efa0a331e31219 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:14:58 +0200 Subject: [PATCH 32/47] fix: global-edit rules for every library entry that pages show (L2) Divi renders any referenced et_pb_layout, whatever its scope term. A normal entry with usages now gets the dry run, confirm, P8a, backup and the site-wide GlobalItem scope; lit-divi-delete's notice and the library-list detail name its pages too. The usage scan is the same single query. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 10 ++-- src/Integrations/Divi/Library/GlobalEdits.php | 56 ++++++++++++------- .../Divi/Library/LibraryListAbility.php | 15 +++-- tests/Support/LibraryFixtures.php | 6 +- .../Divi/Library/GlobalEditsTest.php | 50 ++++++++++++++++- .../Divi/Library/LibraryListAbilityTest.php | 16 +++++- 6 files changed, 117 insertions(+), 36 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index ba3c121..bbac051 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -12,8 +12,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv - `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: - ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen Einträgen die Seiten, die - ihn zeigen (`affected`, `affected_count`). + ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die ihn als globales Modul + zeigen (`affected`, `affected_count`, `hidden_count`). - `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` @@ -42,7 +42,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. 2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. -3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf: Antwort `confirm_required` mit +3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf – ebenso bei einem normalen Eintrag, + den Seiten als globales Modul zeigen (Divi zeigt dort dessen aktuellen Inhalt): Antwort `confirm_required` mit `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts. 4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit @@ -62,7 +63,8 @@ Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiv `unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei). `lit/divi-replace-text` nimmt Bibliothekseinträge als `targets` (ID und `tree_rev` aus `lit-divi-outline`; -der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale nie – ihr +der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale (und normale, +die Seiten als globales Modul zeigen) nie – ihr Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update` und `confirm: true` vornehmen. diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index a3eee20..a75616a 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -22,8 +22,10 @@ defined('ABSPATH') || exit; * The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole * change validated and before anything is saved: * - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type; - * - a global entry changes every page that shows it at once (F7): without confirm: true nothing is - * saved and the answer lists those pages (a dry run of the fully validated change); with it the + * - a global entry – and any entry that pages show as an instance, global scope or not (audit L2: + * Divi renders every referenced entry) – changes every page that shows it at once (F7): without + * confirm: true nothing is saved and the answer lists those pages (a dry run of the fully + * validated change); with it the * save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup * copy is made first – the revision itself is DiviSaver's restore point, as for every page. * Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may @@ -87,17 +89,18 @@ final class GlobalEdits return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]); } - if (! $global) { + // Audit L2: Divi renders every entry an instance names, global scope or not. + $report = $this->usages->report($id); + if (! $global && ! $report->exists()) { return ['scope' => SaveScope::LibraryItem, 'details' => []]; } - $report = $this->usages->report($id); if (($input['confirm'] ?? false) !== true) { return Errors::confirmRequired( sprintf( - 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' - . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', - $id, + '%s: die Änderung wirkt sofort auf %d %s (siehe affected), auch veröffentlichte. Nichts wurde ' + . 'gespeichert (Probelauf).', + self::label($id, $global), $report->count, self::pages($report->count) ) . self::hiddenNote($report) . self::blockedNote($report), @@ -105,7 +108,7 @@ final class GlobalEdits ); } - $refusal = $this->pagesRefusal($id, $report) ?? $this->backupRefusal($item); + $refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item); if ($refusal !== null) { return $refusal; } @@ -119,23 +122,29 @@ final class GlobalEdits } /** - * A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected - * pages – so the one confirm the client sends covers both questions. + * A tool's own confirm_required (lit-divi-delete) on a global or referenced entry (audit L2), + * extended by the affected pages – so the one confirm the client sends covers both questions. */ public function notice(DiviPage $page, WP_Error $error): WP_Error { - if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) { + if (Errors::codeOf($error) !== 'confirm_required') { return $error; } + $global = $this->access->isGlobal($page->post); $report = $this->usages->report((int) $page->post->ID); + if (! $global && ! $report->exists()) { + return $error; + } + $data = $error->get_error_data(); $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; return Errors::confirmRequired( Errors::clientMessage($error) . sprintf( - ' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).', + ' %s: wirkt auf %d %s (siehe affected).', + $global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)', $report->count, self::pages($report->count) ) @@ -149,15 +158,14 @@ final class GlobalEdits * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above * GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1). */ - private function pagesRefusal(int $id, UsageReport $report): ?WP_Error + private function pagesRefusal(string $label, UsageReport $report): ?WP_Error { if ($report->tooMany) { return Errors::unsupported( sprintf( - 'Globaler Bibliothekseintrag %d erscheint auf %d veröffentlichten, privaten oder geplanten ' - . 'Einträgen – mehr als %d; so große Änderungen nimmt LIT MCP nicht vor. Nichts wurde ' - . 'gespeichert.', - $id, + '%s erscheint auf %d veröffentlichten, privaten oder geplanten Einträgen – mehr als %d; so große ' + . 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.', + $label, $report->live, GlobalUsages::HARD_CAP ), @@ -171,9 +179,9 @@ final class GlobalEdits return Errors::forbidden( sprintf( - 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, ' - . 'die dieses Konto nicht bearbeiten darf (%s); nichts wurde gespeichert.', - $id, + '%s erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto nicht ' + . 'bearbeiten darf (%s); nichts wurde gespeichert.', + $label, self::blockedList($report) ), 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' @@ -234,6 +242,14 @@ final class GlobalEdits . ($more > 0 ? sprintf(' und %d weitere', $more) : ''); } + /** How messages name entry $id: global, or a normal entry that pages show as a global module (L2). */ + private static function label(int $id, bool $global): string + { + return $global + ? sprintf('Globaler Bibliothekseintrag %d', $id) + : sprintf('Bibliothekseintrag %d (auf Seiten als globales Modul verwendet)', $id); + } + private static function pages(int $count): string { return $count === 1 ? 'Seite' : 'Seiten'; diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index 85a0c3b..94f2154 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -21,9 +21,10 @@ defined('ABSPATH') || exit; /** * lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page - * of published entries (filters, cursor); with id one entry with its node outline, tree_rev and, - * for a global entry, the pages that show it. Rights: LibraryAccess (edit_posts + divi_library; - * every listed entry also edit_post – others are left out, without title or count). With id, + * of published entries (filters, cursor); with id one entry with its node outline, tree_rev and + * the pages that show it as an instance (global entries always, others when any page does, L2). + * Rights: LibraryAccess (edit_posts + divi_library; every listed entry also edit_post – others are + * left out, without title or count). With id, * everything but a library entry the user may read answers like a missing ID (ruling P12). */ final class LibraryListAbility implements Ability @@ -53,7 +54,7 @@ final class LibraryListAbility implements Ability { return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), ' . 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag ' - . 'im Detail – Knoten-Übersicht, tree_rev und bei globalen Einträgen die Seiten, die ihn zeigen ' + . 'im Detail – Knoten-Übersicht, tree_rev und die Seiten, die ihn als globales Modul zeigen ' . '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.'; } @@ -232,8 +233,10 @@ final class LibraryListAbility implements Ability 'rev' => Rev::ofPost($page->post), 'edit_url' => $page->editUrl(), ]; - if ($item['scope'] === 'global') { - $result += $this->usages->report((int) $page->post->ID, false)->listing(); + // Audit L2: a normal entry that pages show as an instance names them too. + $report = $this->usages->report((int) $page->post->ID, false); + if ($item['scope'] === 'global' || $report->exists()) { + $result += $report->listing(); } return $result; diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php index 19b204e..f59f782 100644 --- a/tests/Support/LibraryFixtures.php +++ b/tests/Support/LibraryFixtures.php @@ -113,10 +113,10 @@ final class LibraryFixtures } /** - * GlobalUsages whose query answers $rows (a closure is asked on every call); the rights checks + * GlobalUsages whose query answers $rows (a closure gets the entry ID on every call); the rights checks * are the real ones, with the post-cache functions stubbed. * - * @param list|\Closure(): list $rows + * @param list|\Closure(int): list $rows */ public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages { @@ -124,7 +124,7 @@ final class LibraryFixtures Functions\when('_prime_post_caches')->justReturn(null); Functions\when('wp_cache_delete')->justReturn(true); $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial(); - $usages->allows('rows')->andReturnUsing(static fn (): array => is_array($rows) ? $rows : $rows()); + $usages->allows('rows')->andReturnUsing(static fn (int $id): array => is_array($rows) ? $rows : $rows($id)); return $usages; } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php index 2d2ba12..18b19ed 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -50,10 +50,17 @@ final class GlobalEditsTest extends TestCase Functions\when('_prime_post_caches')->justReturn(null); } - /** The live posts ($this->live; 1053 published, the others private) and draft 1070. */ + /** @var list Normal (non-global) entries that pages nevertheless show (audit L2). */ + private array $referenced = []; + + /** + * For the global entries and $this->referenced: the live posts ($this->live; 1053 published, the + * others private) and draft 1070. Other entries are shown nowhere. + */ private function usages(): GlobalUsages { - return LibraryFixtures::usages(fn (): array => array_merge( + return LibraryFixtures::usages(fn (int $id): array => ! LibraryFixtures::ITEMS[$id][2] + && ! in_array($id, $this->referenced, true) ? [] : array_merge( array_map( static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'], $this->live @@ -133,6 +140,45 @@ final class GlobalEditsTest extends TestCase self::assertArrayNotHasKey('affected', $result); } + /** + * Audit L2: Divi renders every referenced entry, global scope or not – a normal entry that pages + * show gets the dry run, P8a and the site-wide scope of a global one. + */ + public function testAReferencedNormalEntryIsEditedLikeAGlobalOne(): void + { + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $this->referenced = [1057]; + $this->live = [1053, 1080]; + + $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057)); + self::denyOn('edit_post', [1080]); + $blocked = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057, true)); + LibraryFixtures::given([LibraryFixtures::item(1057)]); + $done = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057, true)); + + self::assertToolError($dryRun, 'confirm_required'); + self::assertStringContainsString('Bibliothekseintrag 1057', $dryRun->get_error_message()); + self::assertStringContainsString('als globales Modul', $dryRun->get_error_message()); + self::assertSame(3, $dryRun->get_error_data()['affected_count']); + self::assertToolError($blocked, 'forbidden'); + self::assertTrue($done['ok']); + self::assertCount(1, $this->saved); + self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]); + } + + public function testDeleteInAReferencedNormalEntryNamesTheAffectedPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + $this->referenced = [1055]; + + $result = (new DiviDeleteAbility($this->writer()))->execute( + ['post_id' => 1055, 'tree_rev' => self::rev(1055), 'ids' => ['s0.r0.c0.m0']] + ); + + self::assertToolError($result, 'confirm_required'); + self::assertSame(2, $result->get_error_data()['affected_count']); + } + public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void { LibraryFixtures::given([LibraryFixtures::item(1059)]); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index d852cd7..f08a26d 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -38,7 +38,9 @@ final class LibraryListAbilityTest extends TestCase )); $access = LibraryFixtures::access($deniedRoles, true, $store); $usages = LibraryFixtures::usages( - static fn (): array => [['id' => 1053, 'status' => 'draft', 'type' => 'page']] + static fn (int $id): array => in_array($id, [1058, 1056], true) + ? [['id' => 1053, 'status' => 'draft', 'type' => 'page']] + : [] ); return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); @@ -185,6 +187,18 @@ final class LibraryListAbilityTest extends TestCase self::assertToolError($this->ability()->permission(['id' => 1058]), 'forbidden'); } + /** Audit L2: a normal entry that a page shows as an instance names that page too. */ + public function testDetailOfAReferencedNormalEntryNamesItsPages(): void + { + LibraryFixtures::given([LibraryFixtures::item(1056)]); + + $result = $this->ability([], [1056])->execute(['id' => 1056]); + + self::assertSame('normal', $result['items'][0]['scope']); + self::assertSame(1, $result['affected_count']); + self::assertSame(1053, $result['affected'][0]['post_id']); + } + public function testDetailOfANormalEntryHasNoAffectedPages(): void { LibraryFixtures::given([LibraryFixtures::item(1055)]); From ce4dc776e50bfc089952dddcd46c5c0a6745b30b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:16:18 +0200 Subject: [PATCH 33/47] fix: read globalModule like Divi's render in the guard and usage search (hardening) StoredNodeGuard accepts a zero-padded library id (Divi intval()s it) but refuses instances whose opener holds more than one or an escaped globalModule, so the entry Divi renders is the one checked. GlobalUsages matches "globalModule":"0*" with REGEXP behind a cheap LIKE prefilter. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Compose/StoredNodeGuard.php | 38 ++++++++++++++++--- .../Divi/Library/GlobalUsages.php | 14 +++---- .../Divi/Compose/StoredNodeGuardTest.php | 33 ++++++++++++++++ .../Divi/Library/GlobalUsagesTest.php | 3 +- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php index 0909f11..aa97226 100644 --- a/src/Integrations/Divi/Compose/StoredNodeGuard.php +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -22,8 +22,9 @@ defined('ABSPATH') || exit; * - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML, * Divi 4 shortcodes), * - an opaque block other than a readable divi/global-layout instance (undecodable attributes, - * a globalModule reference on a plain block, an instance without a digits-only globalModule or - * a usable blockName/localAttrs), + * a globalModule reference on a plain block, an instance without a digits-only globalModule, + * with more than one or an escaped globalModule in its opener, or without a usable + * blockName/localAttrs), * - a code module, * - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26), * also when the builder-only key sits below the state value, @@ -39,8 +40,11 @@ final class StoredNodeGuard { public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; - /** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */ - private const LIBRARY_ID = '/^[1-9][0-9]*$/D'; + /** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */ + private const LIBRARY_ID = '/^[0-9]+$/D'; + + /** Divi's render (BlockParser.php:838) takes the first textual match of this in the opener. */ + private const RENDERED_ID = '/"globalModule":"([0-9]+)"/'; /** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, backslash or controls. */ private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D'; @@ -123,8 +127,7 @@ final class StoredNodeGuard $block->name() !== GlobalInstance::BLOCK || ! str_starts_with($name, 'divi/') || $name === GlobalInstance::BLOCK - || ! is_string($attrs['globalModule'] ?? null) - || preg_match(self::LIBRARY_ID, $attrs['globalModule']) !== 1 + || self::libraryId($block) === null ) { return null; } @@ -137,6 +140,29 @@ final class StoredNodeGuard return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; } + /** + * The entry an instance shows, read like Divi's render (audit I1, hardening): the parsed + * globalModule must be digits naming an ID above 0 ("0123" = 123), and the stored opener must hold + * exactly one "globalModule" – written as plain digits that give the same ID – so the entry Divi + * renders is the one checked here (JSON's last duplicate key would win, Divi's first match). + */ + private static function libraryId(Block $block): ?int + { + $value = $block->attrs()['globalModule'] ?? null; + if (! is_string($value) || preg_match(self::LIBRARY_ID, $value) !== 1 || (int) $value < 1) { + return null; + } + + $raw = $block->rawOpen(); + if ($raw === null) { + return (int) $value; + } + + return substr_count($raw, '"globalModule"') === 1 + && preg_match(self::RENDERED_ID, $raw, $match) === 1 + && (int) $match[1] === (int) $value ? (int) $value : null; + } + /** * One flattened path: the builder-only gate, then every string below it – a breakpoint map's * state values the way AttrWriter hands them to ValueCleaner::clean(). diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 3481c37..f75de41 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -118,7 +118,9 @@ class GlobalUsages } /** - * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. + * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. Like + * Divi's render, "globalModule":"0123" names entry 123 (hardening); the LIKE only narrows the + * rows the REGEXP looks at. * * @return list */ @@ -129,8 +131,9 @@ class GlobalUsages $rows = $db->get_results( $db->prepare( 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' - . "post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', - self::like($db, $libraryId) + . "post_content REGEXP %s AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', + '%' . $db->esc_like('"globalModule":"') . '%', + '"globalModule":"0*' . $libraryId . '"' ), 'ARRAY_A' ); @@ -164,11 +167,6 @@ class GlobalUsages return current_user_can('edit_post', $row['id']); } - private static function like(\wpdb $db, int $libraryId): string - { - return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; - } - private function db(): \wpdb { return $this->db ?? $GLOBALS['wpdb']; diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index 237572b..c82d1af 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -269,6 +269,31 @@ final class StoredNodeGuardTest extends TestCase 'nicht lesbar', true, ], + // Hardening (audit I1): Divi renders the first textual "globalModule":"" of the opener. + 'instance with two library ids (duplicate key)' => [ + '', + 'nicht lesbar', + true, + ], + 'instance with a second library id in its local attributes' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['module' => ['globalModule' => '7']], + ]), + 'nicht lesbar', + true, + ], + 'instance whose library id is escaped in the markup' => [ + '', + 'nicht lesbar', + true, + ], + 'instance with library id zero' => [ + self::node('divi/global-layout', ['globalModule' => '000', 'blockName' => 'divi/text']), + 'nicht lesbar', + true, + ], 'control character' => [self::text('

a' . chr(1) . 'b

'), 'Steuerzeichen'], // Stored $variable references: the same check as client references (VariableRef). 'script in before' => [ @@ -301,6 +326,14 @@ final class StoredNodeGuardTest extends TestCase ]; } + /** Divi renders "0123" as entry 123 (intval) – such an instance is a readable reference. */ + public function testALibraryIdWithLeadingZerosIsAnInstance(): void + { + $block = self::block(self::node('divi/global-layout', ['globalModule' => '0123', 'blockName' => 'divi/text'])); + + self::assertSame([], self::guard()->problems($block)); + } + /** @dataProvider refused */ public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( string $markup, diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index a682cd9..783c9d0 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -38,7 +38,8 @@ final class GlobalUsagesTest extends TestCase ); self::assertCount(1, $queries); self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); - self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); + self::assertStringContainsString("post_content LIKE '%\"globalModule\":\"%'", $queries[0]); + self::assertStringContainsString("post_content REGEXP '\"globalModule\":\"0*1058\"'", $queries[0]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]); From d8d7bb67be7db3e25f88729d5d57566ba7b1500b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:18:04 +0200 Subject: [PATCH 34/47] fix: lit/library-list search by title, own entries for authors, cursor without hidden rows (L3) search uses search_columns post_title; users without edit_others_posts on et_pb_layout query their own entries only (PostGuard::ownPostsOnly); the cursor counts the entries shown, never the rows left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Library/DiviLibraryStore.php | 11 +++++- .../Divi/Library/LibraryListAbility.php | 32 +++++++++------- .../Divi/Library/DiviLibraryStoreTest.php | 17 +++++++++ .../Divi/Library/LibraryListAbilityTest.php | 38 +++++++++++++++++++ 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index 19432c9..363f99f 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -70,8 +70,9 @@ class DiviLibraryStore /** * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). * - * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters - * scope: "global" or "normal"; category: name of a layout_category term. + * @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters + * scope: "global" or "normal"; search: in the title; category: name of a layout_category + * term; author: only this user's entries. * @return list */ public function find(array $filters, int $limit, int $offset): array @@ -101,6 +102,12 @@ class DiviLibraryStore ]; if (($filters['search'] ?? '') !== '') { $args['s'] = $filters['search']; + // Audit L3: the title only – content is block JSON, and entries the user may not read + // must not be findable by their content. + $args['search_columns'] = ['post_title']; + } + if (is_int($filters['author'] ?? null)) { + $args['author'] = $filters['author']; } if ($taxQuery !== []) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index 94f2154..e5b3e80 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -29,7 +29,7 @@ defined('ABSPATH') || exit; */ final class LibraryListAbility implements Ability { - /** At most this many entries are looked at per call while skipping entries the user may not edit. */ + /** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */ public const MAX_SCAN = 500; public function __construct( @@ -158,42 +158,48 @@ final class LibraryListAbility implements Ability ], static fn (mixed $value): bool => is_string($value) && $value !== '' ); + // Audit L3: without edit_others_posts the query holds the own entries only (PostGuard). + $author = PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE); + if ($author !== null) { + $filters['author'] = $author; + } return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset); } /** - * One page of the entries the user may edit. The cursor counts the store's rows, so entries - * left out never shift a page; next_cursor points at the next readable entry, null when none - * follows (or the scan cap is reached with nothing left). + * One page of the entries the user may edit. The cursor counts the entries this user was shown + * ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call + * reads the rows from the start again and skips the first $skip readable ones. next_cursor is + * null when no readable entry follows (or the scan cap is reached with nothing found). * - * @param array $filters + * @param array $filters * @return array{items: list>, next_cursor: ?string} */ - private function page(array $filters, int $limit, int $offset): array + private function page(array $filters, int $limit, int $skip): array { $store = $this->access->store(); $items = []; - $position = $offset; - while ($position - $offset < self::MAX_SCAN) { + $readable = 0; + $position = 0; + while ($position < $skip + self::MAX_SCAN) { $rows = $store->find($filters, $limit, $position); foreach ($rows as $post) { - if ($this->access->canRead($post) !== null) { - ++$position; + ++$position; + if ($this->access->canRead($post) !== null || $readable++ < $skip) { continue; } if (count($items) === $limit) { - return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + return ['items' => $items, 'next_cursor' => Cursor::encode($skip + $limit)]; } $items[] = $store->describe($post); - ++$position; } if (count($rows) <= $limit) { return ['items' => $items, 'next_cursor' => null]; } } - return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + return ['items' => $items, 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items))]; } /** diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php index 74aa01e..a14d665 100644 --- a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -86,6 +86,8 @@ final class DiviLibraryStoreTest extends TestCase self::assertSame(21, $captured['posts_per_page']); self::assertSame(40, $captured['offset']); self::assertSame('Kopf', $captured['s']); + self::assertSame(['post_title'], $captured['search_columns'], 'audit L3: the title only'); + self::assertArrayNotHasKey('author', $captured); self::assertSame( [ 'relation' => 'AND', @@ -97,6 +99,21 @@ final class DiviLibraryStoreTest extends TestCase ); } + /** Audit L3: users without edit_others_posts query their own entries only. */ + public function testFindRestrictsToAnAuthor(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find(['author' => 12], 20, 0); + + self::assertSame(12, $captured['author']); + } + public function testReportsMissingTaxonomies(): void { Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index f08a26d..6b6a24e 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -133,6 +133,44 @@ final class LibraryListAbilityTest extends TestCase self::assertSame(['items', 'next_cursor'], array_keys($first)); } + /** + * Audit L3: the cursor counts only the entries this user was shown – how many hidden entries + * match a search (and sort before) stays unknown. + */ + public function testTheCursorCountsNoHiddenEntries(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1054, 1055]); + $ids = [1054, 1055, 1056, 1057, 1058]; + + $first = $this->ability([], $ids)->execute(['limit' => 1]); + $second = $this->ability([], $ids)->execute(['limit' => 1, 'cursor' => $first['next_cursor']]); + + self::assertSame([1056], array_column($first['items'], 'id')); + self::assertSame(Cursor::encode(1), $first['next_cursor']); + self::assertSame([1057], array_column($second['items'], 'id')); + self::assertSame(Cursor::encode(2), $second['next_cursor']); + } + + /** Audit L3 (author oracle): without edit_others_posts the query itself holds only the own entries. */ + public function testAnAuthorQueriesOnlyTheirOwnEntries(): void + { + LibraryFixtures::given([]); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(12); + $store = LibraryFixtures::store(); + $store->expects('find')->with(['search' => 'Geheim', 'author' => 12], 20, 0)->andReturn([]); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + LibraryFixtures::usages([]) + ); + + self::assertSame(['items' => [], 'next_cursor' => null], $ability->execute(['search' => 'Geheim'])); + } + public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void { LibraryFixtures::given([]); From 031f01451b777663725e0ce8e9cfc3077a3cfeb2 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:19:05 +0200 Subject: [PATCH 35/47] fix: note the library-list scan cap; preset-cap note only with presets (M-11, M-12) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Design/DiviDesignSystemAbility.php | 2 +- .../Divi/Library/LibraryListAbility.php | 17 ++++++++-- .../Design/DiviDesignSystemAbilityTest.php | 4 +++ .../Divi/Library/LibraryListAbilityTest.php | 34 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php index 1ecf8e8..065d50c 100644 --- a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -152,7 +152,7 @@ final class DiviDesignSystemAbility implements Ability static fn (array $preset): bool => $preset['module'] === $name )); } - if (count($presets) > self::PRESET_LIMIT) { + if (in_array('presets', $only, true) && count($presets) > self::PRESET_LIMIT) { $notes[] = sprintf( '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.', self::PRESET_LIMIT, diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index e5b3e80..48f8862 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -105,6 +105,7 @@ final class LibraryListAbility implements Ability 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], 'hidden_count' => ['type' => 'integer'], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], ], 'required' => ['items', 'next_cursor'], ]; @@ -174,7 +175,7 @@ final class LibraryListAbility implements Ability * null when no readable entry follows (or the scan cap is reached with nothing found). * * @param array $filters - * @return array{items: list>, next_cursor: ?string} + * @return array{items: list>, next_cursor: ?string, notes?: list} */ private function page(array $filters, int $limit, int $skip): array { @@ -199,7 +200,19 @@ final class LibraryListAbility implements Ability } } - return ['items' => $items, 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items))]; + // Review M-12: say why the page is short, so the client neither thinks the list ended nor loops. + return [ + 'items' => $items, + 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items)), + 'notes' => [sprintf( + 'Nach %d gelesenen Bibliothekseinträgen abgebrochen (Einträge ohne Bearbeitungsrecht werden ' + . 'übersprungen); %s', + self::MAX_SCAN, + $items === [] + ? 'weitere Einträge mit Filtern (layout_type, scope, search, category) eingrenzen.' + : 'mit next_cursor weiterblättern oder mit Filtern eingrenzen.' + )], + ]; } /** diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php index 26b7cc7..c91a888 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -75,5 +75,9 @@ final class DiviDesignSystemAbilityTest extends TestCase self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']); self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']); + + $colors = (new DiviDesignSystemAbility($reader))->execute(['only' => ['colors']]); + self::assertSame([], $colors['presets']); + self::assertStringNotContainsString('Presets gezeigt', implode(' ', $colors['notes']), 'not requested'); } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index 6b6a24e..7cddffb 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -14,6 +14,7 @@ use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use WP_Error; +use WP_Post; final class LibraryListAbilityTest extends TestCase { @@ -171,6 +172,39 @@ final class LibraryListAbilityTest extends TestCase self::assertSame(['items' => [], 'next_cursor' => null], $ability->execute(['search' => 'Geheim'])); } + /** Review M-12: when the scan cap stops a page, the answer says so. */ + public function testSaysWhenTheScanCapIsReached(): void + { + LibraryFixtures::given([]); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) >= 5000) + ); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_get_object_terms')->justReturn([]); + $hidden = array_map( + static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'et_pb_layout', 'post_title' => 'x']), + range(5000, 5000 + LibraryListAbility::MAX_SCAN + 10) + ); + $posts = array_merge([LibraryFixtures::item(1055)], $hidden, [LibraryFixtures::item(1056)]); + $store = LibraryFixtures::store(); + $store->allows('find')->andReturnUsing( + static fn (array $filters, int $limit, int $offset): array => array_slice($posts, $offset, $limit + 1) + ); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + LibraryFixtures::usages([]) + ); + + $result = $ability->execute(['limit' => 5]); + + self::assertSame([1055], array_column($result['items'], 'id')); + self::assertSame(Cursor::encode(1), $result['next_cursor']); + self::assertStringContainsString('500', $result['notes'][0]); + } + public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void { LibraryFixtures::given([]); From 1c2acdfbbee9f042a26ca3ae702beb19cdd78b63 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:20:03 +0200 Subject: [PATCH 36/47] fix: node-copy from a library entry unifies CRLF; smoke seeds a real CRLF entry (M-1, M-2/M-19) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Pages/DiviNodeCopyAbility.php | 5 ++++ tests/Integration/divi-library-smoke.sh | 27 ++++++++++++++++--- .../Divi/Pages/DiviNodeCopyAbilityTest.php | 2 ++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php index 148dddc..ec99827 100644 --- a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -139,6 +139,11 @@ final class DiviNodeCopyAbility extends DiviWriteAbility } $copy = $node->copy(); + if ($source->isLibraryItem()) { + // Finding 9: library entries from Divi's dialog use CRLF; unified before every takeover + // (LibraryContent::load), as lit-library-insert and lit/library-save do. + $copy->unifyLineBreaks(); + } $problems = $composition->storedGuard()->problems($copy); if ($problems !== []) { return Errors::invalidInput( diff --git a/tests/Integration/divi-library-smoke.sh b/tests/Integration/divi-library-smoke.sh index d61c376..73c65c8 100755 --- a/tests/Integration/divi-library-smoke.sh +++ b/tests/Integration/divi-library-smoke.sh @@ -212,7 +212,20 @@ echo "$LISTED" | jq -e --argjson l "$LIB_ID" --argjson g "$GLOB_ID" ' ability lit/library-list "{\"id\":$GLOB_ID}" | jq -e '.outline | length > 0' >/dev/null || fail "library-list detail of $GLOB_ID" pass "lit/library-save global: bytes, terms and meta like Divi's entry; lit/library-list shows both" -# 4. Insert the global entry as instance, the normal entry as copy: bytes as the Visual Builder writes them +# A normal section entry with the CRLF bytes of Divi's library dialog (stage3 fixture as-is, finding 9). +CRLF_ID=$(LIT_FILE="$STAGE3/library--library-abschnitt.html" LIT_TITLE="$PREFIX CRLF" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "et_pb_layout", "post_status" => "publish", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); +wp_set_object_terms($id, "section", "layout_type"); +wp_set_object_terms($id, "regular", "module_width"); +wp_set_object_terms($id, "non_global", "scope"); +update_post_meta($id, "_et_pb_template_type", "section"); +update_post_meta($id, "_et_pb_built_for_post_type", "page"); +echo $id;') +CREATED_POSTS+=("$CRLF_ID") +content_of "$CRLF_ID" | grep -q $'\r' || fail "setup: the CRLF entry holds no CR" + +# 4. Insert the global entry as instance, the CRLF entry as copy: bytes as the Visual Builder writes them BEFORE=$(content_of "$TARGET_ID") INST=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(rev_of "$TARGET_ID")" --argjson lib "$GLOB_ID" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: "s0.r0.c0", index: 0}')") @@ -222,7 +235,7 @@ EXPECTED_INSTANCE=$(snippet instance | sed "s/\"globalModule\":\"1059\"/\"global || fail "instance bytes differ from the Visual Builder's: $(content_of "$TARGET_ID")" outline "$TARGET_ID" | jq -e --arg g "$GLOB_ID" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance" BEFORE=$(content_of "$TARGET_ID") -COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \ +COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$CRLF_ID" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") echo "$COPY" | jq -e '.ok == true and .changed[0].id == "s1"' >/dev/null || fail "library-insert copy: $COPY" [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "copy bytes differ from the entry's section" @@ -269,7 +282,15 @@ NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg echo "$NODE_COPY" | jq -e '.ok == true and .revision_id != null' >/dev/null || fail "node-copy: $NODE_COPY" [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy bytes differ from the source node" [ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "node-copy changed the source page" -pass "lit/divi-node-copy: stale source_tree_rev refused, copy byte-exact, source unchanged" +BEFORE=$(content_of "$TARGET_ID") +LIB_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$NODE_COPY" | jq -r .tree_rev)" \ + --argjson src "$CRLF_ID" --arg srev "$(rev_of "$CRLF_ID")" \ + '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')") +echo "$LIB_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy from the CRLF entry: $LIB_COPY" +[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy from a library entry: bytes differ" +if content_of "$TARGET_ID" | grep -q $'\r'; then fail "node-copy brought CRLF into the page"; fi +NODE_COPY=$LIB_COPY +pass "lit/divi-node-copy: stale source_tree_rev refused, copy byte-exact (CRLF unified from a library entry), source unchanged" # 7. {{link:ID}} in a button URL: Divi's post_link_url_page; a draft target renders only ?page_id= TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev') diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php index 21c1aaa..7ad4241 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -203,6 +203,8 @@ final class DiviNodeCopyAbilityTest extends TestCase self::assertTrue($result['ok']); self::assertStringContainsString('Library-Blurb', $this->saved[0]); + self::assertStringContainsString(chr(13), (string) $item->post_content, 'the entry uses CRLF'); + self::assertStringNotContainsString(chr(13), $this->saved[0], 'line breaks unified like LibraryContent::load'); } /** @return array */ From ae994ad7bdbe6524ca5af49185dad28189047983 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:20:44 +0200 Subject: [PATCH 37/47] fix: lit/divi-dynamic-sources offers no {{link:}} for attachments (M-9) Also none for internal post types (post_link_url__et_pb_speculation). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Pages/DiviDynamicSourcesAbility.php | 9 ++++++++- .../Divi/Pages/DiviDynamicSourcesAbilityTest.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php index 056109e..9863548 100644 --- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -205,7 +205,14 @@ final class DiviDynamicSourcesAbility implements Ability return '{{post:' . $post . '}}'; } - return str_starts_with($name, 'post_link_url_') ? '{{link:}}' : null; + if (! str_starts_with($name, 'post_link_url_')) { + return null; + } + + // Review M-9: {{link:}} refuses attachments (status inherit); internal types ("_…") are no link targets. + $type = substr($name, strlen('post_link_url_')); + + return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:}}'; } /** diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php index b6726da..8eabe10 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php @@ -80,6 +80,22 @@ final class DiviDynamicSourcesAbilityTest extends TestCase self::assertSame('lit/divi-dynamic-sources', $this->ability()->name()); } + /** Review M-9: {{link:}} refuses attachments (status inherit), so their link sources get no hint. */ + public function testNoLinkHintForAttachmentsOrInternalTypes(): void + { + $registry = DiviFixtures::dynamicContentRegistry(); + foreach (['post_link_url_attachment', 'post_link_url__et_pb_speculation'] as $name) { + $registry[$name] = $registry['post_link_url_page']; + $registry[$name]['id'] = $name; + } + + $sources = array_column($this->ability([], 'meta', $registry)->execute(['post_type' => 'page'])['sources'], null, 'name'); + + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['post_link_url_attachment']['placeholder']); + self::assertNull($sources['post_link_url__et_pb_speculation']['placeholder']); + } + public function testOffersTextLikeFieldsAndNamesTheOthers(): void { $result = $this->ability()->execute(['post_type' => 'page']); From 695f7b91ac6ea20776513982f73d850174f75649 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:21:25 +0200 Subject: [PATCH 38/47] docs: neutral client wording in the plugin header and the spec (T16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header says "mit OAuth für KI-Assistenten" like the readme; the spec names the client neutrally and keeps claude.ai only where the connector is meant. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/2026-09-25-wp-lit-mcp-design.md | 12 +++++------ tests/Unit/PluginHeaderTest.php | 20 +++++++++++++++++++ wp-lit-mcp.php | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/PluginHeaderTest.php diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index 8ec3f0b..5647575 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -12,12 +12,12 @@ Die Pflege von Kunden-Websites (WordPress + Divi 5 + Pods) kostet viel Handarbei ### 1.2 Ziel -Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem Claude (claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.** +Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem ein KI-Assistent (z. B. über den claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.** ### 1.3 Erfolgskriterien 1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen. -2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe). +2. Vom KI-Assistenten erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe). 3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt) – aber nur, wenn die Seite noch nicht in Divis `serialize_blocks()`-Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff); über Builder oder LIT MCP gespeicherte Seiten bleiben unverändert (E2E Abschnitt 8a); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8). 4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen. 5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot). @@ -180,7 +180,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ - `lit/divi-outline` liefert den Baum mit Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. - Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16). -- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. +- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; der Client liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. - Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent. ### 5.4 Modulbeschreibung @@ -354,7 +354,7 @@ Regeln: - Vor jeder Änderung wird der betroffene Bereich (Farben, Variablen, Presets oder Theme-Optionen) als **Snapshot** gesichert (eigene Option, begrenzte Anzahl, Standard 20). Snapshot-ID im `ToolResult` und im Protokoll. - Voraussetzung: `edit_theme_options`; für Variablen zusätzlich Divis Rollen-Recht `variables_manager`. -Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): Claude liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben. +Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): der KI-Assistent liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben. ## 8. Rückmeldung, Fehler, Protokoll, Einstellungen @@ -394,7 +394,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer - **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots. - Umsetzung mit Settings API, ohne JS-Framework. -## 9. Anleitungen für Claude +## 9. Anleitungen für den KI-Assistenten ### 9.1 Server-Instructions @@ -465,7 +465,7 @@ Referenz: https://modelcontextprotocol.io/specification |---|---|---| | **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein | | **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen | -| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen | +| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit dem KI-Assistenten bauen und pflegen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | | **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – | diff --git a/tests/Unit/PluginHeaderTest.php b/tests/Unit/PluginHeaderTest.php new file mode 100644 index 0000000..2b18b04 --- /dev/null +++ b/tests/Unit/PluginHeaderTest.php @@ -0,0 +1,20 @@ + Date: Sun, 27 Sep 2026 04:22:04 +0200 Subject: [PATCH 39/47] refactor: DiviSaver docblocks, one diviWritable field, REASON_CREATE with the constants (M-4, M-5, M-13) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/DiviIntegration.php | 10 ++++---- .../Divi/Library/DiviLibraryStore.php | 6 ++--- src/Integrations/Divi/Pages/DiviSaver.php | 24 ++++++++++--------- src/Plugin.php | 2 +- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 2702d5f..92bdc1d 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -57,7 +57,7 @@ final class DiviIntegration implements Integration * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group * "Divi-Bibliothek" not disabled, ruling P9); null = always. * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy - * follows it (ruling P9, E9); null = always. + * 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. */ @@ -128,10 +128,10 @@ final class DiviIntegration implements Integration } /** - * The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access; - * $diviWritable is whether lit/divi-insert is enabled (lit-library-insert follows it, audit M2). + * The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access and its + * $diviWritable gate (lit-library-insert follows lit/divi-insert, audit M2). */ - public function library(?Closure $diviWritable = null): LibraryIntegration + public function library(): LibraryIntegration { $loader = $this->loader(); @@ -143,7 +143,7 @@ final class DiviIntegration implements Integration new DiviSaver(), $this->log, $this->modules, - $diviWritable + $this->diviWritable ); } diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index 363f99f..7da3e2b 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -34,6 +34,9 @@ class DiviLibraryStore /** Taxonomies every new entry needs (et_pb_submit_layout). */ public const TAXONOMIES = ['scope', 'layout_type', 'module_width']; + /** Client text when the terms or meta of a new entry could not be written. */ + public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.'; + public function isGlobal(int $id): bool { if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) { @@ -138,9 +141,6 @@ class DiviLibraryStore ]; } - /** Client text when the terms or meta of a new entry could not be written. */ - public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.'; - /** * A published entry with the terms and meta of $spec and empty content (E4; the layout follows * through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index ac6d639..5cbaa39 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -40,18 +40,19 @@ defined('ABSPATH') || exit; * those hooks except the content itself (already cleaned); anything a third-party hook writes * there is saved without kses, as it would be for an administrator. The rollback write in * restore() uses the same window with the previous stored content; saveNew() (the first layout - * of a new draft, lit/content-create) uses it for its single wp_update_post(). + * of a new post – a draft of lit/content-create or a library entry of lit/library-save) uses it + * for its single wp_update_post(). * * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data * AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and - * DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first two re-serialize - * the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such + * DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first + * two re-serialize the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such * pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * - * SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache - * aller Seiten und den Theme-Builder-Cache. + * SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears + * the static CSS of every page and the Theme Builder cache. * * If saving throws, the previous content is written back (WriteFailed: rolled back or not). * Not final so abilities can be tested with a mock. @@ -131,12 +132,13 @@ class DiviSaver } /** - * The first content of a brand-new draft (lit/content-create with divi, which inserted the - * draft with its normal WordPress fields – title and excerpt went through kses as usual). - * Same kses window as save(), holding only this wp_update_post() and its hooks; no restore - * point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part - * of the write here: without them the new entry is no Divi layout. Every failure throws – - * the caller deletes the draft (all or nothing). + * The first content of a brand-new post: a draft of lit/content-create with divi or a library + * entry of lit/library-save, both inserted with their normal WordPress fields first (title and + * excerpt went through kses as usual). Same kses window as save(), holding only this + * wp_update_post() and its hooks; no restore point (the post has no earlier state) and no + * static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them + * a new page is no Divi layout); SaveScope::LibraryItem sets none. Every failure throws – the + * caller deletes the new post (all or nothing). * * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException diff --git a/src/Plugin.php b/src/Plugin.php index 4bfc0dd..4b4c5d8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -108,7 +108,7 @@ final class Plugin new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), $divi, - $divi->library($diviWritable) + $divi->library() ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); From be5dde2b647c3f93ddef96fd479db4da9b7d8a3b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:23:06 +0200 Subject: [PATCH 40/47] feat: DiviCompatibility checks the TB cache function, divi/global-layout and the library term taxonomies (M-6) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/DiviCompatibility.php | 40 ++++++++++++++++++- .../Divi/DiviCompatibilityTest.php | 3 ++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php index 434ad87..73c96e1 100644 --- a/src/Integrations/Divi/DiviCompatibility.php +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; @@ -13,8 +14,9 @@ defined('ABSPATH') || exit; /** * Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes, - * functions, the library post type and taxonomies. lit-site-status shows the failed checks, so a - * Divi update that moves one of them is visible before a tool reports "unsupported". + * functions, the library post type and taxonomies, the instance block type. lit-site-status shows + * the failed checks, so a Divi update that moves one of them is visible before a tool reports + * "unsupported". */ final class DiviCompatibility { @@ -56,6 +58,40 @@ 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'), ]; } + + /** + * Whether Divi still has the block type of global instances (GlobalInstance::BLOCK): registered + * with WordPress, or in Divi's core module metadata (Divi renders instances itself, BlockParser, + * and does not always register the type with WordPress). + */ + private static function knowsGlobalLayout(): bool + { + if ( + class_exists('WP_Block_Type_Registry') + && \WP_Block_Type_Registry::get_instance()->is_registered(GlobalInstance::BLOCK) + ) { + return true; + } + + $registration = 'ET\Builder\Packages\ModuleLibrary\ModuleRegistration'; + if (! method_exists($registration, 'get_all_core_modules_metadata')) { + return false; + } + + foreach ((array) $registration::get_all_core_modules_metadata() as $module) { + if (is_array($module) && ($module['name'] ?? null) === GlobalInstance::BLOCK) { + return true; + } + } + + return false; + } } diff --git a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php index f3840e7..cb3aae4 100644 --- a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php +++ b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php @@ -34,6 +34,9 @@ final class DiviCompatibilityTest extends TestCase 'Taxonomien scope, layout_type, module_width (Bibliothek)', 'GlobalLayout::is_global_layout_template (globale Module)', 'ET_Core_PageResource (CSS-Cache)', + 'et_theme_builder_clear_wp_cache (Theme-Builder-Cache)', + 'Block-Typ divi/global-layout (Instanzen globaler Module)', + 'Taxonomien layout_category, layout_tag (Bibliothek)', ], array_keys(DiviCompatibility::defaults()) ); From dcbd52e51dd7c865038a08ad2c31f3d1387585aa Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:24:01 +0200 Subject: [PATCH 41/47] docs: acceptance note without CLI, with the editor connection and the read-only hint (M-15) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../notes/2026-09-27-etappe-3a-abnahme.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md b/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md index 190e47a..29bf872 100644 --- a/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md @@ -38,9 +38,8 @@ als kurze Notiz zurückmelden; sie werden Folgeaufgaben. Standard (nicht „nur lesen“, nicht deaktiviert). - [ ] Den KI-Assistenten neu verbinden, damit er die neuen Tools und Hinweise lädt. In einer neuen Unterhaltung ist `lit-library-insert` sichtbar. -- [ ] Nach einem Lauf von `divi-library-smoke.sh` ist nichts übrig: - `cd /home/janniclas/Projekte/WordPress && ddev wp post list --post_type=et_pb_layout,page --post_status=any --s="LIT Smoke" --format=count` - ergibt `0`. +- [ ] Nach einem Lauf von `divi-library-smoke.sh` ist nichts übrig: unter Seiten → Alle Seiten und unter + Divi → Divi-Bibliothek nach „LIT Smoke“ suchen (jeweils auch im Papierkorb) – keine Treffer. - [ ] Zwei Entwurfsseiten mit dem Divi Builder anlegen, z. B. „Abnahme 3a – Seite A“ und „Abnahme 3a – Seite B“, jede mit mindestens einem Abschnitt, einer Zeile und einem Textmodul. Die „[Fixture]“- und „[Test]“-Inhalte nicht verwenden und nicht ändern. @@ -100,13 +99,21 @@ als kurze Notiz zurückmelden; sie werden Folgeaufgaben. - [ ] Einstellungen → LIT MCP → Tools: die Gruppe „Divi-Bibliothek“ ist sichtbar, mit `lit-library-insert`, `lit/library-list` und `lit/library-save`. - [ ] Gruppe auf „nur lesen“: nach neuer Verbindung fehlt `lit-library-insert` in der Werkzeugliste; - Bibliothekseinträge lassen sich lesen (`lit-divi-outline`), Änderungen daran werden abgelehnt. + Bibliothekseinträge lassen sich lesen (`lit-divi-outline`), Änderungen daran werden abgelehnt, und + `lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle wird ebenfalls abgelehnt. + Hinweis: Steht dagegen die Gruppe „Divi“ auf „nur lesen“, bleibt `lit-library-insert` in der Liste, lehnt + aber jedes Einfügen ab (`unsupported`, „Divi-Änderungen sind … abgeschaltet“) – das ist so gewollt, kein + Fehler. - [ ] Gruppe deaktivieren: nach neuer Verbindung fehlen die Bibliothekstools; eine Bibliotheks-ID gilt in `lit-divi-outline` als nicht vorhanden. Seiten lassen sich weiter lesen und bearbeiten. - [ ] Die Gruppe wieder auf ihren Standard stellen und neu verbinden. -- [ ] Optional mit einem Redakteurs-Konto (unter Zugriff freigegeben): in Divi → Rollen-Editor für „Redakteur“ - „Globale Bibliothekselemente bearbeiten“ abschalten und den Assistenten ein globales Modul ändern lassen: - Ablehnung mit Hinweis auf den Rollen-Editor. Danach das Recht wieder einschalten. +- [ ] Optional mit einem Redakteurs-Konto: unter Einstellungen → LIT MCP → Zugriff die Rolle „Redakteur“ + freigeben. Im KI-Assistenten die Verbindung trennen und neu verbinden; bei der Anmeldung (OAuth) mit dem + Redakteurs-Konto einloggen – am einfachsten in einem privaten Browserfenster, in dem niemand bei + WordPress angemeldet ist. Dann in Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente + bearbeiten“ abschalten und den Assistenten ein globales Modul ändern lassen: Ablehnung mit Hinweis auf + den Rollen-Editor. Danach das Recht wieder einschalten, die Freigabe der Rolle zurücknehmen und den + Assistenten wieder als Administrator verbinden. - [ ] `lit-site-status`: bei Divi keine Einträge unter `problems`. ## Aufräumen From 25446d292db2b4e591878b20247ff53cc6161cbf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:25:53 +0200 Subject: [PATCH 42/47] test: divi-library smoke hygiene (M-14) Per-run editor login and role, deleted only when this run created them (and refused when they exist); the allowed-roles option is deleted again when it was absent; the run-token fallback covers pages too; readOnlyHint must be false, not just missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/Integration/divi-library-smoke.sh | 43 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/tests/Integration/divi-library-smoke.sh b/tests/Integration/divi-library-smoke.sh index 73c65c8..3a6f806 100755 --- a/tests/Integration/divi-library-smoke.sh +++ b/tests/Integration/divi-library-smoke.sh @@ -13,15 +13,26 @@ cd /var/www/html source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) -EDITOR_LOGIN=lit-lib-editor -EDITOR_ROLE=lit_lib_editor STAGE3=$PLUGIN/tests/Fixtures/divi/stage3 RUN="$(date +%s)-$$" PREFIX="LIT Smoke $RUN" -ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') +# Per-run names: cleanup never touches an account or role that existed before this run. +EDITOR_LOGIN="lit-lib-editor-$RUN" +EDITOR_ROLE="lit_lib_editor_${RUN//-/_}" +EDITOR_CREATED=0 +ROLE_CREATED=0 +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '__none__') ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__') CREATED_POSTS=() +restore_roles() { + if [ "$ORIGINAL_ROLES" = "__none__" ]; then + wp option delete lit_mcp_allowed_roles >/dev/null 2>&1 || true + else + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true + fi +} + restore_exposure() { if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then wp option delete lit_mcp_exposure >/dev/null 2>&1 || true @@ -31,12 +42,13 @@ restore_exposure() { } cleanup() { - # Library entries of this run whose ID a stopped run could not record (title prefix with the run token). + # Pages and library entries of this run whose ID a stopped run could not record (title prefix with the run token). local found found=$(LIT_PREFIX="$PREFIX " wp eval ' global $wpdb; echo implode(" ", $wpdb->get_col($wpdb->prepare( - "SELECT ID FROM $wpdb->posts WHERE post_type = %s AND post_title LIKE %s", + "SELECT ID FROM $wpdb->posts WHERE post_type IN (%s, %s) AND post_title LIKE %s", + "page", "et_pb_layout", $wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%" )));' 2>/dev/null || true) @@ -59,10 +71,14 @@ foreach (array_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as wp post delete "$id" --force >/dev/null 2>&1 || true done cleanup_app_passwords_for "$ADMIN_LOGIN" - cleanup_app_passwords_for "$EDITOR_LOGIN" - wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true - wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true - wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true + if [ "$EDITOR_CREATED" = 1 ]; then + cleanup_app_passwords_for "$EDITOR_LOGIN" + wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true + fi + if [ "$ROLE_CREATED" = 1 ]; then + wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true + fi + restore_roles restore_exposure } trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT @@ -161,7 +177,7 @@ connect "$ADMIN_LOGIN" # 1. Exposure: lit-library-insert direct, the other new tools indirect, 22 direct lit tools LIST=$(rpc tools/list '{}') -echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint != true' >/dev/null \ +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint == false' >/dev/null \ || fail "lit-library-insert is not a direct write tool" for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi @@ -368,10 +384,15 @@ done pass "admin (unfiltered_html) may save interactions, Lottie source and custom HTML to the library" # 10. Temporary editor without unfiltered_html and without edit_private_pages +if wp user get "$EDITOR_LOGIN" >/dev/null 2>&1 || wp role exists "$EDITOR_ROLE" >/dev/null 2>&1; then + fail "user $EDITOR_LOGIN or role $EDITOR_ROLE exists already" +fi wp role create "$EDITOR_ROLE" "LIT Smoke Bibliothek" --clone=editor >/dev/null +ROLE_CREATED=1 wp cap remove "$EDITOR_ROLE" unfiltered_html edit_private_pages >/dev/null wp option update lit_mcp_allowed_roles "[\"administrator\",\"$EDITOR_ROLE\"]" --format=json >/dev/null -wp user create "$EDITOR_LOGIN" lit-lib-editor@example.invalid --role="$EDITOR_ROLE" >/dev/null +wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role="$EDITOR_ROLE" >/dev/null +EDITOR_CREATED=1 [ "$(LIT_LOGIN="$EDITOR_LOGIN" LIT_ID="$PRIVATE_ID" wp eval ' $u = get_user_by("login", getenv("LIT_LOGIN")); echo (user_can($u, "unfiltered_html") ? "html " : "") . (user_can($u, "edit_post", (int) getenv("LIT_ID")) ? "private" : "");')" = "" ] \ From f1937ca6fffc055214bd1bfff2247e8843fa5da2 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:25:53 +0200 Subject: [PATCH 43/47] test: file-based editor byte-identity regression; filters fixture with JSON escapes (P20) synthetic-divi-filters.html now stores its paragraph as Divi does (unicode escapes, bytes built with chr(92) and checked with od). A data-provider test inserts a section as a user without unfiltered_html into every Divi 5 fixture page read from disk and asserts that every other byte stays. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Fixtures/divi/synthetic-divi-filters.html | 2 +- .../Divi/Pages/DiviEditorByteIdentityTest.php | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php diff --git a/tests/Fixtures/divi/synthetic-divi-filters.html b/tests/Fixtures/divi/synthetic-divi-filters.html index 2103459..e310d16 100644 --- a/tests/Fixtures/divi/synthetic-divi-filters.html +++ b/tests/Fixtures/divi/synthetic-divi-filters.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php new file mode 100644 index 0000000..ab2696e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviEditorByteIdentityTest.php @@ -0,0 +1,69 @@ + */ + public static function pages(): array + { + $pages = []; + foreach (['', 'stage3/'] as $dir) { + foreach (glob(dirname(__DIR__, 4) . '/Fixtures/divi/' . $dir . '*.html') ?: [] as $file) { + $name = $dir . basename($file); + if ($name !== 'divi4-shortcodes.html') { + $pages[$name] = [$name]; + } + } + } + + return $pages; + } + + /** @dataProvider pages */ + public function testAnEditorInsertKeepsEveryOtherByte(string $fixture): void + { + $original = DiviFixtures::markup($fixture); + self::assertNotSame('', $original, $fixture); + DiviFixtures::givenPost($original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $saved = []; + + $result = (new DiviInsertAbility(DiviWriterFixture::writer($saved, unfiltered: false)))->execute([ + 'post_id' => 7, + 'tree_rev' => Rev::of($original), + 'nodes' => [['type' => 'section']], + ]); + + self::assertTrue($result['ok'] ?? false, $fixture . ': ' . json_encode($result)); + self::assertCount(1, $saved); + $node = NodeIndex::build((new Parser())->parse($saved[0]))->find($result['changed'][0]['id']); + self::assertNotNull($node, $fixture); + $inserted = Serializer::block($node); + $at = strpos($saved[0], $inserted); + self::assertNotFalse($at, $fixture); + self::assertSame( + $original, + substr($saved[0], 0, (int) $at) . substr($saved[0], (int) $at + strlen($inserted)), + $fixture . ': only the new section was added' + ); + } +} From fec5d9da7beb8588ba5cb0ef145825cb38649011 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:42:46 +0200 Subject: [PATCH 44/47] fix: GlobalUsages finds every reference Divi renders, fail closed (F1, ruling P23) Candidates come from one ID/status/type query: content with globalModule, global_module or a JSON-escaped letter. Their content is read in chunks of 50 and parsed by UsageMatcher: Divi 5 openers are json-decoded (escaped keys and values, leading zeros, any depth), and Divi 4 global_module attributes are read in any quoting. An undecodable escaped opener counts as a possible reference. Beyond 5000 candidates the rest count unread. The guide names the formats and the wp_block parity. Also fixes the grammar of the blocked-hidden count. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 7 +- src/Integrations/Divi/Library/GlobalEdits.php | 4 +- .../Divi/Library/GlobalUsages.php | 76 +++++++++++++--- .../Divi/Library/UsageMatcher.php | 87 +++++++++++++++++++ .../Divi/Library/GlobalEditsTest.php | 2 +- .../Divi/Library/GlobalUsagesTest.php | 79 +++++++++++++---- .../Divi/Library/UsageMatcherTest.php | 78 +++++++++++++++++ 7 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 src/Integrations/Divi/Library/UsageMatcher.php create mode 100644 tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index bbac051..1fbf003 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -12,8 +12,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv - `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type` (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: - ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die ihn als globales Modul - zeigen (`affected`, `affected_count`, `hidden_count`). + ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen + (`affected`, `affected_count`, `hidden_count`). Gezählt wird jeder Verweis, den Divi darstellt: Divi-5-Instanzen + in jeder Schreibweise und Divi-4-Shortcodes mit `global_module`; ein Verweis, der sich nicht eindeutig lesen + lässt, zählt vorsichtshalber mit. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint + dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst. - `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev` diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index a75616a..45b8ec5 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -224,9 +224,9 @@ final class GlobalEdits } if ($report->blockedHidden > 0) { $parts[] = sprintf( - '%d %s, die dieses Konto nicht lesen darf', + '%d %s dieses Konto nicht lesen darf', $report->blockedHidden, - $report->blockedHidden === 1 ? 'Eintrag' : 'Einträge' + $report->blockedHidden === 1 ? 'Eintrag, den' : 'Einträge, die' ); } diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index f75de41..9fd86eb 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -10,9 +10,10 @@ defined('ABSPATH') || exit; /** * Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every - * living post whose content has "globalModule":"" – Divi's own way to find instances (string - * scan, BlockParserStore, F10). One query per report (ID, status and type only – never the content, - * audit L1); the rights of the caller are then checked in chunks with a bounded post cache: + * living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5 + * instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come + * from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the + * rights of the caller are then checked in chunks with a bounded post cache: * - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4); * - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when * readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option @@ -33,6 +34,12 @@ class GlobalUsages /** Posts whose rights are checked with one primed cache. */ public const CHUNK = 200; + /** Candidates whose content is read and parsed per call; the rest count as references (ruling P23). */ + public const PARSE_CAP = 5000; + + /** Candidate contents read per query (bounded memory, audit L1). */ + public const CONTENT_CHUNK = 50; + /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ public const LIVE = ['publish', 'private', 'future']; @@ -118,22 +125,45 @@ class GlobalUsages } /** - * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. Like - * Divi's render, "globalModule":"0123" names entry 123 (hardening); the LIKE only narrows the - * rows the REGEXP looks at. + * Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The + * candidates come from one query (ID, status and type only): content with "globalModule", a + * Divi 4 "global_module" or a JSON escape of a letter (an escaped key such as globalModule + * always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by + * UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1). * * @return list */ public function rows(int $libraryId): array + { + $candidates = $this->candidates(); + $result = []; + foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) { + $contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk)); + foreach ($chunk as $row) { + // A candidate whose content could not be read counts too (fail closed). + if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) { + $result[] = $row; + } + } + } + + return array_merge($result, array_slice($candidates, self::PARSE_CAP)); + } + + /** @return list */ + private function candidates(): array { $db = $this->db(); + $escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $db->get_results( $db->prepare( - 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' - . "post_content REGEXP %s AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', - '%' . $db->esc_like('"globalModule":"') . '%', - '"globalModule":"0*' . $libraryId . '"' + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (post_content LIKE %s OR ' + . "post_content LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " + . self::LISTED . ' ORDER BY ID ASC', + '%' . $db->esc_like('globalModule') . '%', + '%' . $db->esc_like('global_module') . '%', + $escapedLetter ), 'ARRAY_A' ); @@ -151,6 +181,32 @@ class GlobalUsages return $result; } + /** + * @param list $ids + * @return array Content by ID. + */ + private function contents(array $ids): array + { + $db = $this->db(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery + $rows = $db->get_results( + $db->prepare( + 'SELECT ID, post_content FROM ' . $db->posts . ' WHERE ID IN (' + . implode(', ', array_fill(0, count($ids), '%d')) . ')', + $ids + ), + 'ARRAY_A' + ); + // phpcs:enable + + $contents = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $contents[(int) $row['ID']] = (string) $row['post_content']; + } + + return $contents; + } + /** * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or * template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php new file mode 100644 index 0000000..6b8e912 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -0,0 +1,87 @@ +" Divi's render takes (leading zeros = intval), and + * every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a + * globalModule with that ID at any depth; + * - Divi 4: a shortcode attribute global_module= in any quoting (intval); + * - an opener with JSON escapes (or a globalModule) that cannot be decoded, or content the regex + * engine cannot scan, counts as a possible reference. + * Pure PHP. + */ +final class UsageMatcher +{ + /** A block opener and its attribute JSON (the Parser's delimiter grammar). */ + private const OPENER = '/).)*+)?})\s+\/?-->/s'; + + /** Divi 4: global_module=, any quote characters (straight, typographic, entities) before the digits. */ + private const SHORTCODE = '/\bglobal_module\s*=\s*[^\d\s\]]{0,12}(\d+)/i'; + + public static function shows(string $content, int $id): bool + { + $textual = preg_match('/"globalModule":"0*' . $id . '"/', $content); + if ($textual !== 0) { + return true; // A match, or a regex failure (fail closed). + } + + if (stripos($content, 'global_module') !== false) { + $found = preg_match_all(self::SHORTCODE, $content, $matches); + if ($found === false) { + return true; + } + foreach ($matches[1] as $value) { + if ((int) $value === $id) { + return true; + } + } + } + + $escape = chr(92) . 'u'; + if (! str_contains($content, 'globalModule') && ! str_contains($content, $escape)) { + return false; + } + + $found = preg_match_all(self::OPENER, $content, $matches); + if ($found === false) { + return true; + } + foreach ($matches[1] as $json) { + if (! str_contains($json, $escape) && ! str_contains($json, 'globalModule')) { + continue; + } + $attrs = json_decode($json, true); + if (! is_array($attrs) || self::names($attrs, $id)) { + return true; + } + } + + return false; + } + + /** @param array $attrs Whether a globalModule key at any depth names $id. */ + private static function names(array $attrs, int $id): bool + { + foreach ($attrs as $key => $value) { + if ( + $key === 'globalModule' + && (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1)) + && (int) $value === $id + ) { + return true; + } + if (is_array($value) && self::names($value, $id)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php index 18b19ed..ade02b3 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -342,7 +342,7 @@ final class GlobalEditsTest extends TestCase self::assertStringNotContainsString('1081', (string) wp_json_encode($data)); self::assertToolError($refused, 'forbidden'); self::assertStringNotContainsString('1081', $refused->get_error_message()); - self::assertStringContainsString('nicht lesen darf', $refused->get_error_message()); + self::assertStringContainsString('1 Eintrag, den dieses Konto nicht lesen darf', $refused->get_error_message()); self::assertSame([], $this->saved); } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 783c9d0..87a5f31 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -13,37 +13,78 @@ use WP_Post; final class GlobalUsagesTest extends TestCase { - /** Audit L1: one query, ID, status and type only – never the content, no COUNT, no second scan. */ - public function testFindsInstancesLikeDiviWithOneQueryWithoutContent(): void + /** + * Ruling P23 (re-review F1): candidates by a wide, fail-closed prefilter (ID, status and type + * only), then their content in chunks, parsed in PHP (UsageMatcher) – every format Divi renders. + */ + public function testFindsEveryFormatDiviRendersFromCandidates(): void { + $bs = chr(92); + $contents = [ + 1053 => '', + 1054 => '[et_pb_section global_module="01058"][/et_pb_section]', + 1055 => '', + 1056 => '', + 1057 => '', + 1061 => '', + ]; $db = Mockery::mock(\wpdb::class)->makePartial(); $queries = []; - $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries, $contents): array { $queries[] = $sql; + if (str_contains($sql, 'post_content FROM')) { + preg_match('/IN \(([0-9, ]+)\)/', $sql, $in); + $ids = array_map('intval', explode(',', $in[1])); - return [ - ['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'], - ['ID' => '1061', 'post_status' => 'private', 'post_type' => 'et_header_layout'], - ]; + return array_map(static fn (int $id): array => ['ID' => (string) $id, 'post_content' => $contents[$id]], $ids); + } + + return array_map( + static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'publish', 'post_type' => 'page'], + array_keys($contents) + ); }); $rows = (new GlobalUsages($db))->rows(1058); - self::assertSame( - [ - ['id' => 1053, 'status' => 'draft', 'type' => 'page'], - ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], - ], - $rows - ); - self::assertCount(1, $queries); + self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed'); self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); - self::assertStringContainsString("post_content LIKE '%\"globalModule\":\"%'", $queries[0]); - self::assertStringContainsString("post_content REGEXP '\"globalModule\":\"0*1058\"'", $queries[0]); + self::assertStringNotContainsString('post_content,', $queries[0]); + self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]); + self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]); + self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); - self::assertStringNotContainsString('LIMIT', $queries[0]); - self::assertStringNotContainsString('post_content,', $queries[0]); + self::assertCount(2, $queries, 'one candidate query, one content chunk'); + } + + /** Audit L1: contents are read in chunks; beyond the parse cap candidates count as references. */ + public function testReadsContentsInChunksAndFailsClosedBeyondTheCap(): void + { + $total = GlobalUsages::PARSE_CAP + 3; + $db = Mockery::mock(\wpdb::class)->makePartial(); + $chunks = 0; + $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$chunks, $total): array { + if (str_contains($sql, 'post_content FROM')) { + ++$chunks; + preg_match('/IN \(([0-9, ]+)\)/', $sql, $in); + + return array_map( + static fn (string $id): array => ['ID' => trim($id), 'post_content' => 'nichts'], + explode(',', $in[1]) + ); + } + + return array_map( + static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'draft', 'post_type' => 'page'], + range(1, $total) + ); + }); + + $rows = (new GlobalUsages($db))->rows(1058); + + self::assertSame((int) ceil(GlobalUsages::PARSE_CAP / GlobalUsages::CONTENT_CHUNK), $chunks); + self::assertSame(range(GlobalUsages::PARSE_CAP + 1, $total), array_column($rows, 'id')); } /** Audit L4: readable posts with title, unreadable ones only counted. */ diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php new file mode 100644 index 0000000..ed861dc --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -0,0 +1,78 @@ + Content => whether it shows entry 123. */ + public static function contents(): array + { + $escapedKey = '"' . self::u('0067') . 'lobalModule"'; + + return [ + 'divi 5 canonical' => ['', true], + 'divi 5 leading zeros' => ['', true], + 'divi 5 escaped key' => ['', true], + 'divi 5 escaped value' => [ + '', + true, + ], + 'divi 5 escaped key with leading zeros' => [ + '', + true, + ], + 'divi 5 another entry' => ['', false], + 'divi 5 escaped key, another entry' => ['', false], + 'divi 5 id in local attributes' => [ + '', + true, + ], + 'divi 4 double quotes' => ['[et_pb_section global_module="123"][/et_pb_section]', true], + 'divi 4 single quotes and zeros' => ["[et_pb_text global_module='0123']x[/et_pb_text]", true], + 'divi 4 unquoted' => ['[et_pb_row global_module=123 admin_label="x"][/et_pb_row]', true], + 'divi 4 typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]', true], + 'divi 4 another entry' => ['[et_pb_section global_module="1230"][/et_pb_section]', false], + 'undecodable escaped opener' => [ + '', + true, + ], + 'undecodable escaped opener naming nothing readable' => [ + '', + true, + ], + 'escaped markup only' => [ + '', + false, + ], + 'plain page' => ['', false], + ]; + } + + /** @dataProvider contents */ + public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void + { + self::assertSame($shows, UsageMatcher::shows($content, 123)); + } + + public function testTheEscapeBytesAreReal(): void + { + self::assertSame(6, strlen(self::u('0067'))); + self::assertSame(92, ord(self::u('0067')[0])); + } +} From 8f87ec6163dfd77f3e3546373fce87df657a27a5 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:42:59 +0200 Subject: [PATCH 45/47] docs: library guide speaks of references in every place (P23) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 1fbf003..a8bb292 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -46,7 +46,7 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. 2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. 3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf – ebenso bei einem normalen Eintrag, - den Seiten als globales Modul zeigen (Divi zeigt dort dessen aktuellen Inhalt): Antwort `confirm_required` mit + auf den Seiten verweisen (Divi zeigt dort dessen aktuellen Inhalt): Antwort `confirm_required` mit `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts. 4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit @@ -67,7 +67,7 @@ Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiv `lit/divi-replace-text` nimmt Bibliothekseinträge als `targets` (ID und `tree_rev` aus `lit-divi-outline`; der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale (und normale, -die Seiten als globales Modul zeigen) nie – ihr +auf die Seiten verweisen) nie – ihr Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update` und `confirm: true` vornehmen. From b6d791d46a93736a384435c235fcf80bd4725525 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:53:54 +0200 Subject: [PATCH 46/47] fix: UsageMatcher reads Divi 4 shortcodes like WordPress and Divi, everywhere (ruling P24) - G1: every decoded string value of a Divi 5 opener is scanned for shortcodes; outside the openers only the remaining content is. - G2: attributes are parsed like shortcode_parse_atts() and global_module is cast with (int), as Divi does. - G3: a non-empty global_module other than "0" that casts to 0 makes Divi render the newest layout, so it counts for every entry (fail closed). - The prefilter LIKEs use LOWER(post_content), so they are case-insensitive under any collation. - The guide describes the fail-closed rule without an absolute claim. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 8 +- .../Divi/Library/GlobalUsages.php | 9 +- .../Divi/Library/UsageMatcher.php | 138 +++++++++++++++--- .../Divi/Library/GlobalUsagesTest.php | 5 +- .../Divi/Library/UsageMatcherTest.php | 45 ++++++ 5 files changed, 172 insertions(+), 33 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index a8bb292..6fb9c54 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -13,9 +13,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen - (`affected`, `affected_count`, `hidden_count`). Gezählt wird jeder Verweis, den Divi darstellt: Divi-5-Instanzen - in jeder Schreibweise und Divi-4-Shortcodes mit `global_module`; ein Verweis, der sich nicht eindeutig lesen - lässt, zählt vorsichtshalber mit. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint + (`affected`, `affected_count`, `hidden_count`). Erkannt werden Divi-5-Instanzen (auch mit maskierten Zeichen) + und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite + mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist + (Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen + können trotzdem unerkannt bleiben. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst. - `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 9fd86eb..adb5e66 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -127,7 +127,7 @@ class GlobalUsages /** * Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The * candidates come from one query (ID, status and type only): content with "globalModule", a - * Divi 4 "global_module" or a JSON escape of a letter (an escaped key such as globalModule + * Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule * always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by * UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1). * @@ -158,10 +158,11 @@ class GlobalUsages // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $db->get_results( $db->prepare( - 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (post_content LIKE %s OR ' - . "post_content LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " + // Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation. + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR ' + . "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', - '%' . $db->esc_like('globalModule') . '%', + '%' . $db->esc_like('globalmodule') . '%', '%' . $db->esc_like('global_module') . '%', $escapedLetter ), diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php index 6b8e912..96c47c0 100644 --- a/src/Integrations/Divi/Library/UsageMatcher.php +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -7,13 +7,16 @@ namespace LIT\MCP\Integrations\Divi\Library; defined('ABSPATH') || exit; /** - * Whether post content shows library entry $id (ruling P23, re-review F1), fail closed: + * Whether post content shows library entry $id (rulings P23, P24), fail closed: * - Divi 5: the textual "globalModule":"" Divi's render takes (leading zeros = intval), and * every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a * globalModule with that ID at any depth; - * - Divi 4: a shortcode attribute global_module= in any quoting (intval); - * - an opener with JSON escapes (or a globalModule) that cannot be decoded, or content the regex - * engine cannot scan, counts as a possible reference. + * - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an + * opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module + * cast with (int) like Divi; a non-empty value other than "0" that casts to 0 makes Divi render the + * newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry; + * - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content + * the regex engine cannot scan, counts as a possible reference. * Pure PHP. */ final class UsageMatcher @@ -22,8 +25,12 @@ final class UsageMatcher private const OPENER = '/).)*+)?})\s+\/?-->/s'; - /** Divi 4: global_module=, any quote characters (straight, typographic, entities) before the digits. */ - private const SHORTCODE = '/\bglobal_module\s*=\s*[^\d\s\]]{0,12}(\d+)/i'; + /** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */ + private const TAG = '/\[\[?([\w-]+)(?![\w-])([^\]\/]*(?:\/(?!\])[^\]\/]*)*?)\/?\]/'; + + /** WordPress' get_shortcode_atts_regex(). */ + private const ATTS = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*' + . '([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/'; public static function shows(string $content, int $id): bool { @@ -32,40 +39,107 @@ final class UsageMatcher return true; // A match, or a regex failure (fail closed). } - if (stripos($content, 'global_module') !== false) { - $found = preg_match_all(self::SHORTCODE, $content, $matches); - if ($found === false) { - return true; - } - foreach ($matches[1] as $value) { - if ((int) $value === $id) { - return true; - } - } - } - - $escape = chr(92) . 'u'; - if (! str_contains($content, 'globalModule') && ! str_contains($content, $escape)) { - return false; - } - $found = preg_match_all(self::OPENER, $content, $matches); if ($found === false) { return true; } + + // Shortcodes outside the openers (Divi 4 pages, free text between blocks). + $outside = $found === 0 ? $content : str_replace($matches[1], '', $content); + if (self::shortcodeShows($outside, $id)) { + return true; + } + + $escape = chr(92) . 'u'; foreach ($matches[1] as $json) { - if (! str_contains($json, $escape) && ! str_contains($json, 'globalModule')) { + if ( + ! str_contains($json, $escape) + && ! str_contains($json, 'globalModule') + && stripos($json, 'global_module') === false + ) { continue; } $attrs = json_decode($json, true); if (! is_array($attrs) || self::names($attrs, $id)) { return true; } + foreach (self::strings($attrs) as $value) { + if (self::shortcodeShows($value, $id)) { + return true; + } + } } return false; } + /** Whether a Divi 4 shortcode in $text renders entry $id (or, casting to 0, any entry). */ + private static function shortcodeShows(string $text, int $id): bool + { + if (stripos($text, 'global_module') === false) { + return false; + } + + $found = preg_match_all(self::TAG, $text, $tags); + if ($found === false) { + return true; + } + foreach ($tags[2] as $attributes) { + $atts = self::parseAtts($attributes); + if ($atts === null) { + return true; + } + $value = $atts['global_module'] ?? null; + // Divi: ! empty( $global_module_id ), then WP_Query p => (int) $global_module_id. + if (! is_string($value) || $value === '' || $value === '0') { + continue; + } + $cast = (int) $value; + if ($cast === 0 || $cast === $id) { + return true; + } + } + + return false; + } + + /** + * shortcode_parse_atts() of WordPress 6.9 (named attributes only); null when PCRE fails. + * + * @return array|null + */ + private static function parseAtts(string $text): ?array + { + $text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text); + if (! is_string($text)) { + return null; + } + + $found = preg_match_all(self::ATTS, $text, $matches, PREG_SET_ORDER); + if ($found === false) { + return null; + } + + $atts = []; + foreach ($matches as $m) { + foreach ([1, 3, 5] as $key) { + if (! empty($m[$key])) { + $atts[strtolower($m[$key])] = stripcslashes($m[$key + 1]); + break; + } + } + } + + // WordPress rejects values with unclosed HTML elements. + foreach ($atts as $name => $value) { + if (str_contains($value, '<') && preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value) !== 1) { + $atts[$name] = ''; + } + } + + return $atts; + } + /** @param array $attrs Whether a globalModule key at any depth names $id. */ private static function names(array $attrs, int $id): bool { @@ -84,4 +158,20 @@ final class UsageMatcher return false; } + + /** + * @param array $attrs + * @return list Every string value at any depth. + */ + private static function strings(array $attrs): array + { + $strings = []; + array_walk_recursive($attrs, static function (mixed $value) use (&$strings): void { + if (is_string($value)) { + $strings[] = $value; + } + }); + + return $strings; + } } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 87a5f31..92b09da 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -50,8 +50,9 @@ final class GlobalUsagesTest extends TestCase self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed'); self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); self::assertStringNotContainsString('post_content,', $queries[0]); - self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]); - self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]); + // Ruling P24: case-insensitive whatever the column collation. + self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]); + self::assertStringContainsString("LOWER(post_content) LIKE '%global" . $bs . "_module%'", $queries[0]); self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php index ed861dc..dbac8cd 100644 --- a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -61,9 +61,54 @@ final class UsageMatcherTest extends TestCase false, ], 'plain page' => ['', false], + // Ruling P24 G1: a Divi 4 shortcode inside a Divi 5 attribute value (quotes serialized as escapes). + 'shortcode in a divi 5 text value' => [self::inText('[et_pb_section global_module=' . self::u('0022') . '123' + . self::u('0022') . '][/et_pb_section]'), true], + 'shortcode in a divi 5 text value, another entry' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . '124' . self::u('0022') . '][/et_pb_section]'), false], + 'shortcode in a divi 5 text value, escaped digits' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . self::u('0031') . self::u('0032') . '3' . self::u('0022') . '][/et_pb_section]'), true], + // G2: parsed like shortcode_parse_atts, cast with (int) like Divi. + 'divi 4 leading space' => ['[et_pb_section global_module=" 123"][/et_pb_section]', true], + 'divi 4 exponent' => ['[et_pb_section global_module="1.23e2"][/et_pb_section]', true], + 'divi 4 upper-case attribute' => ['[et_pb_section GLOBAL_MODULE="123"][/et_pb_section]', true], + 'divi 4 zero renders nothing' => ['[et_pb_section global_module="0"][/et_pb_section]', false], + 'divi 4 empty renders nothing' => ['[et_pb_section global_module=""][/et_pb_section]', false], + 'global_module as plain text' => ['

global_module="123" steht hier nur als Text

', false], ]; } + /** A Divi 5 text module whose content value holds $text, JSON-encoded like WordPress does. */ + private static function inText(string $text): string + { + return ''; + } + + /** + * Ruling P24 G3: a global_module Divi casts to 0 renders the newest library or TB layout + * (et_pb_load_global_module, WP_Query p=0) – a reference to every entry. + * + * @return array + */ + public static function wildcards(): array + { + return [ + 'entity before the id' => ['[et_pb_section global_module=""123"][/et_pb_section]'], + 'letters' => ['[et_pb_row global_module="abc"][/et_pb_row]'], + 'typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]'], + 'double zero' => ['[et_pb_row global_module="00"][/et_pb_row]'], + 'inside a divi 5 value' => [self::inText('[et_pb_text global_module=' . self::u('0022') . 'x' + . self::u('0022') . ']a[/et_pb_text]')], + ]; + } + + /** @dataProvider wildcards */ + public function testAValueThatCastsToZeroShowsEveryEntry(string $content): void + { + self::assertTrue(UsageMatcher::shows($content, 123)); + self::assertTrue(UsageMatcher::shows($content, 98765)); + } + /** @dataProvider contents */ public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void { From e049959482ced39e7dfd4219d529d8c2a5051d24 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:57:43 +0200 Subject: [PATCH 47/47] docs: Etappe 3a rulings and security audit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../notes/2026-09-27-etappe-3a-rulings.md | 111 +++++++++ .../2026-09-27-etappe-3a-security-audit.md | 210 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md create mode 100644 docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md new file mode 100644 index 0000000..7c49e63 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md @@ -0,0 +1,111 @@ +# Etappe 3a – Entscheidungen (Rulings), Befunde und offene Punkte + +Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3a-security-audit.md. + +## Offen für spätere Etappen +- Etappe 3b: vollständiges Ausblenden von Bibliotheks-IDs bei deaktivierter Gruppe (P13); gleiche Zielregeln für rohe post_link_url_* wie für {{link:}} (P10); Theme Builder, find-similar, make-global. +- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren. +- Etappe U: try/catch um Divi-Aufrufe (P11), einheitliche Fehlercodes der Tools-Tab-Sperren, doppelte Helfer, Test-Feinschliff, „blurb ist leer“-Warnung, Rollen-Option in divi-write-smoke. +- Restrisiko (Low, P25): sehr ungewöhnliche Schreibweisen globaler Verweise auf Seiten, die der Benutzer nicht bearbeiten darf, können der Verwendungssuche entgehen (setzt Bearbeitungsrechte an der verweisenden Seite voraus). + +## Pre-flight rulings +Pre-flight (opus): 0 Critical, 8 Important, 19 Minor, 4 Info; fixtures match byte for byte; {{link:ID}} to draft/private shows only ?page_id=. Full tables: preflight.md. +- Ruling P1 (I-1, → Task 9): lit/divi-replace-text on library entries goes through DiviWriter::commit() (dry run/confirm for global, site-wide cache clear, no Divi flags on library posts) or refuses library targets — implementer picks the variant that reuses commit(); F2 binding — cost if wrong: none +- Ruling P2 (I-2, → Task 11): rebuild the 3 test rows with Divi's \u0022 escaping and assert the parsed block is not opaque — cost if wrong: none +- Ruling P3 (I-3, → Task 11): StoredNodeGuard refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, opaque blocks and rich-text HTML that is not kses-stable when moving stored nodes between posts — cost if wrong: editors copy less (visible refusal) +- Ruling P4 (I-4, → Task 2): only the link row uses URL mode in the new placeholder test — cost if wrong: none +- Ruling P5 (I-5, → Task 13): lit-library-insert reads the entry via LibraryAccess (edit_post) — cost if wrong: none +- Ruling P6 (I-6, → Task 12): lit/library-save reuses NewDraft (partial_write + recordPartial on failed rollback) — cost if wrong: none +- Ruling P7 (I-7, → Task 12): scope "global" additionally requires edit_global_library (Divi's rule, F12) — cost if wrong: none +- Ruling P8 (I-8, → Task 9): a confirmed global edit is refused when the user cannot edit_post one of the published pages that render the entry (stricter than Divi; security first) — cost if wrong: editors with partial rights can't change shared modules +- Ruling P9 (minors): confirm stays honour-based (D10); a disabled library group hides library IDs from the Divi read tools; lit/divi-node-copy follows lit/divi-insert exposure (E9); refuse the backup path when a user without unfiltered_html edits with revisions off or trash disabled (kses backup loses bytes); output schema gains affected, affected_count, backup_id; unknown dynamic field types are omitted (F4); dispatch notes per preflight (OutlineTest anchor ×3, >120-char lines, dead variable) — cost if wrong: small +- Note: common.md fixed — PostChangeResult was renamed to PostSaveFollowUp in 2b W3 + +## Progress +- Task 1: dispatched (sonnet, BASE cfc81dd) +- Task 1: complete (404f0f3, trailer normalized; 2-line config change verified by controller; 1597 tests) +- Task 2: dispatched (opus, BASE 404f0f3; P4, link read_post check) +- Task 2: implementer done (788943e; 1609 tests); note T16 docs: attachments refused by {{link:}} +- Task 2: review (opus): Approved; live: read_post enforced (private/foreign draft/trash refused, uniform message), ID forms refused, URL-only on all paths, no leak +- Ruling P10 (T2 minors → final wave): full-form post_link_url_* uses LinkTargets (same target rules as {{link:}}; trashed/unattached attachment consistent) + one shared ID parser; attachments refused by {{link:}} documented in T16 — cost if wrong: none +- Task 2: complete (404f0f3..788943e) +- Tasks 3–5: dispatched as one batch (sonnet, BASE 788943e, one commit per task) +- Tasks 3–5: review (sonnet): all Approved (858aa6a, a3a8070, b389f98 after trailer normalization; 1617 tests); global id in outline/node-get = library post ID only (accepted, like GlobalUsages); minors → final wave: preset-cap note only when presets requested; Outline duplicates Block::shortName prefix strip +- Tasks 3, 4, 5: complete (788943e..b389f98) +- Tasks 6–7: dispatched (opus, BASE b389f98, one commit per task) +- Tasks 6–7: implementer done (ce1ad7c, 2700fd6; 1646 tests); T7 deviations: unifyLineBreaks skips opaque blocks; deglobalize keeps invalid blockName instance opaque +- Tasks 6–7: review (opus): both Approved; byte fidelity independently verified incl. deglobalize == Divi's own PortabilityPost output; deviations accepted +- Ruling P11 (T6/T7 minors): → T12/T13 dispatch: refuse opaque/global roots before GlobalInstance::of (no nested instances); → final wave: try/catch Throwable around Divi calls in DiviLibraryStore/DiviRoles (E15 never fatal), stdClass localAttrs, self-closing of() for created childless roots, replaceChild attached-check, isSpecialtyColumn by effectiveName +- Tasks 6, 7: complete (b389f98..2700fd6) +- Task 8: dispatched (opus, BASE 2700fd6; P9 library-group hiding hook) +- Task 8: implementer done (48ecc62; 1660 tests); P9 hook LibraryAccess $visible + ExposurePolicy::groupEnabled, wired as libraryVisible (GROUP 'divi-library', live with T10); T9 must merge: $libraryWritable param + LibraryAccess::GROUP already exist +- Task 8: review (opus): Approved; live with temp editor/author/contributor/subscriber + role-editor right withdrawn; P9 hook verified with real ExposurePolicy +- Ruling P12 (T8 I-1): users without the Divi library right at all (canList false) get PostGuard::notFound (no existence/type oracle, like P9); missing edit_post on an entry stays `forbidden` (project convention for pages) — fix round 1 now — cost if wrong: less helpful hint for users lacking the role right +- Ruling P13 (T8 M-1/M-2 → final wave / T10): with the library group hidden, page outline/node-get omit global_id and the render-summary hint; T10 keeps groupEnabled semantics (group mode only) +- Task 8: fix round 1 (P12; 681bb75; 1661 tests; live: logged-out 1058 vs 99999 byte-identical not_found) — verified by controller against report + live claim +- Task 8: complete (2700fd6..681bb75). Note for T10+: canList()/canRead() still return forbidden when called directly (fine for list tool — no ID oracle) +- Task 9: dispatched (opus, BASE 681bb75; F2, P1, P8, P9 backup refusal + schema) +- Task 9: implementer done (d66ad36; 1684 tests; divi-write smoke ok; live dry run on 1059 lists 1053, bytes unchanged); concerns: backup id not named on failure after backup; P8 published only +- Task 9: review (opus): live verified dry run/confirm/revision/reference semantics/P8 refusal/no flags; changes requested: I-1 P8 gap (private page: editor without rights changed what it shows), I-2 backup_id lost on failed save after backup +- Ruling P8a (amends P8): confirmed global edit requires edit_post on every affected post with status publish, private or future (incl. TB layouts/other library entries found by the query); drafts/pending listed in dry run but not blocking — private/future render or will render — cost if wrong: editors blocked more often +- Ruling P14: WriteFailed after a backup carries backup_id + hint (WriteFailed::withDetails); dry run warns with blocked_ids when P8 would refuse (M-1); plural fix (M-2); shared status-parameterised query (M-3); _prime_post_caches before P8 loop (M-4); M-5/M-6 → final wave/T16 +- Task 9: fix round 1 dispatched +- Task 9: fix round 1 (P8a, P14, M-1..M-4; 520a639; 1691 tests); re-review running +- Task 9: re-review clean (live: private/future block, draft not, blocked_ids in dry run, backup_id on failed save) +- Task 9: complete (681bb75..520a639) +- Task 10: dispatched (opus, BASE 520a639; P12, P13 if cheap, per-entry filtering) +- Task 10: implementer done (b7c7149; 1702 tests); P12 via item() for id; P13 → final wave +- Task 10: review (sonnet): Approved; live: group disabled → list unregistered + read tools not_found; nur lesen → list ok, writes forbidden; P12 id byte-identical; minor: MAX_SCAN cap silent → final wave +- Task 10: complete (520a639..b7c7149) +- Task 11: dispatched (opus, BASE b7c7149; P2, P3, stored $variable refs via VariableRef) +- Task 11: implementer done (f8275ad; 1741 tests; 30 malicious rows); concerns: readable global instances checked (localAttrs) not refused (per brief/preflight I-3); builder-written $variable refs often refused for editors (stricter than Divi); plain text kses-checked only when containing '<'; ValueCleaner::kindAt added +- Ruling P15: accept checking readable global instances (a reference renders existing library content; Divi lets library-right users insert them too); stricter $variable handling accepted (visible refusal, documented in T16) — cost if wrong: editors can't copy some builder nodes +- Task 11: review (opus, SEC): Approved (guard holds for P3/P15; Divi renders instances from published entries only, localAttrs+snapshot both checked). NEW Critical (pre-existing since 2b): module.decoration.interactions writable by editors → effect addAttribute onclick="…" → Divi's interactions script setAttribute → script on click; path is known in catalog so lit/divi-update can write it +- Ruling P16: add decoration.interactions to ValueCleaner::BUILDER_ONLY (S7 gate: nur im Divi-Builder für Benutzer ohne unfiltered_html) — closes main pipeline and StoredNodeGuard; separate commit with tests (full form, short form if any, stored guard, removal via null); also scan the catalog for other paths whose values Divi turns into attributes/HTML/script (e.g. other effect/attribute/script-like option groups) and gate them the same way, listing what was found — cost if wrong: editors can't set interactions via MCP +- Ruling P17 (T11 minors): guard URL rule `url($v) === trim($v)`; guard enforces VALUE_KEY key names and digits-only globalModule; STATE_KEYS reuses AttrWriter::STATES; >120-char test lines — in the same fix round +- Task 11: fix round 1 (P16 9d92808 interactions builder-only + scan: nothing else to gate; P17 e808832; 1748 tests; smoke ok) +- Ruling P18 (P17 too strict for relative links): StoredNodeGuard URL accept rule = url($v) === trim($v), OR the value has no scheme (no ':' before the first '/', '?', '#') and contains no whitespace, quotes, '<', '>' or control chars — relative links (kontakt/, /pfad, #anker, ?q=1) stay copyable, every scheme still must survive url() unchanged — cost if wrong: none (scheme-less values can't carry javascript:) +- Task 11: fix round 2 (P18; 16c6d59; 1750 tests); backslash hosts also refused, //host must survive url() +- Task 11: re-review: P16/P17/P18 verified live (interactions refused for editors incl. null removal + guard; 63 URL paths × entity-colon payloads safe; relative rule holds); NEW Important: divi/lottie lottie.innerContent.src → remote JSON loaded by lottie-web with expressions (eval) → script; editors could set it +- Ruling P19: divi/lottie lottie.innerContent (src and any animation-source keys) builder-only for users without unfiltered_html (same S7 gate as P16), in main pipeline and StoredNodeGuard; tests set/null/copy; guides list it — cost if wrong: editors can't set Lottie via MCP +- Task 11: fix round 3 (P19; 8fa285a; 1753 tests; smoke ok) verified by controller (pattern + tests) +- Note Etappe 4: Divi global presets can carry a Lottie src / interactions — preset writes (design system) must apply the same builder-only gates +- Task 11: complete (b7c7149..8fa285a) +- Task 12: dispatched (opus, BASE 8fa285a; P6, P7, P11, guard) +- Task 12: implementer done (5035092; 1783 tests; live: bytes equal Divi entry 1055, page unchanged, no flags); nested globals kept as references in normal entries (plan-conform, accepted) +- Task 12: review (opus): Approved; live: rights (edit_post source, publish_posts, divi_library/save_library, edit_global_library), guard refuses all builder-only kinds as editor, P11, P6 rollback + partial_write, terms/meta identical to Divi, exposure; bytes equal after CRLF→LF; minors → final wave: REASON_CREATE placement, test line length, partial_write hint for new entries +- Task 12: complete (8fa285a..5035092) +- Task 13: dispatched (opus, BASE 5035092; P5, P11, guard in copy mode, direct budget 22) +- Task 13: implementer done (7fe91c2; 1799 tests; smoke ok; live: 1059/1058 instance and 1055 copy byte-identical to fixture, probes cleaned) +- Task 13: review (opus): Approved; live: P5 (author vs admin/private/fixture forbidden, missing not_found), P11 incl. instance into global entry refused by shapeViolation, copy-mode guard refusals as editor, exposure 22 direct; minors → final wave (test line length, multi-root unit test, "zu bearbeiten" wording) +- Task 13: complete (5035092..7fe91c2) +- NEW (pre-existing, Important): editor saves via any Divi write tool rewrite untouched

→

(some content_save_pre filter still active despite kses removal); no CHANGED_ON_SAVE; tree_rev may mismatch stored content → investigation dispatched (opus) in parallel with Task 14 +- Task 14: dispatched (opus, BASE 7fe91c2); parallel: editor-rewrite investigation (read-only on code) +- Task 14: implementer done (6f6c691; 1814 tests; smoke ok); source_tree_rev required check; not live-probed +- Investigation editor rewrite: NOT reproducible (45 inputs, editor+admin, plain wp_update_post in kses window and end-to-end lit/divi-insert: byte-identical, tree_rev == Rev::of(stored)); likely tool-chain \u decoding in the reviewer's probe (known artifact); CHANGED_ON_SAVE detection works (wp_strip_custom_css_from_blocks + Divi AttributeSecurity both detected). Ruling P20: no kses-window change; final wave adds a file-based editor regression test and switches synthetic-divi-filters.html to \u003c escapes — cost if wrong: none +- Task 14: review (opus): Approved; live via registered ability: source access P5/P12, guard refusals as editor (7 kinds), P11, same-page byte identity, restore point, source/target rev locks, exposure matrix; minors → final wave: shared source-node helper (dup with LibrarySave), OUTLINE_HINT reuse, test line length, 'after'=>null in test +- Task 14: complete (7fe91c2..6f6c691) +- Task 15: dispatched (opus, BASE 6f6c691; F4, P9 unknown types, allow-map equivalence test) +- Task 15: implementer done (8808a7d; 1827 tests; allow map byte-identical JSON snapshot); concerns → final wave: {{link:}} hint for attachments vs P10, choice fields show value not label (document) +- Task 15: review (sonnet): Approved; allow-map snapshot test mutation-verified (non-vacuous); sample post only editable by user (implementer fixed brief leak); live page 26 sources +- Task 15: complete (6f6c691..8808a7d) +- Task 16: dispatched (opus, BASE 8808a7d; docs must describe actual T1–T15 behaviour, collected points in dispatch) +- Task 16: implementer done (2729e28; 1829 tests; v0.4.0); library line in instructions only when group writable; spec 'Claude' wording → final wave +- Task 16 review (sonnet) and Task 17 (opus, BASE 2729e28) dispatched in parallel (disjoint files: docs vs tests/Integration + abnahme note) +- Task 16: review (sonnet): Approved (docs verified against code: 22 direct, result keys, P8a/P9/P12/P16/P19/P3/P15–P18, link rules, version, topics, neutral wording); → final wave: wp-lit-mcp.php plugin header still says "mit OAuth für claude.ai"; internal spec "Claude" wording +- Task 16: complete (8808a7d..2729e28) +- Task 17: done (186f7af; 6 smokes ALL CHECKS PASSED; 1829 tests); Ruling P21: T17 (test script + acceptance note only) is reviewed within the final whole-branch review instead of a separate task review — cost if wrong: none +- Task 17: complete (2729e28..186f7af) +- Final: whole-branch review (opus) + security audit (opus) dispatched in parallel on cfc81dd..186f7af +- Final review (opus): ready with fixes; must: I-1 node-copy from library skips add_library (+ library-insert exposure), I-2 P13 guide vs code +- Security audit (opus): no Critical/High/Medium; Low: L1 DoS in P8a/usages (loads full posts), L2 referenced-but-not-global entry skips dry run/P8a (live), L3 library-list search covers content + decodable cursor → existence oracle for authors (live), L4 dry run lists id/status/type of unreadable posts, L5 = I-1, L6 TB layouts accepted on WP caps only; hardening: guard vs first globalModule id, confirm not bound to dry run, "0123" refs +- Ruling P22: final fix wave per final-fix-brief.md (must + audit L1–L6 + cheap review items); P13 full hiding and P10 → Etappe 3b; guide sentence fixed now — cost if wrong: none +- Final fix wave: 17/17 (1b42f9b..f1937ca; 1868 tests; 6 smokes ok); decisions: referenced normal entries get dry run/confirm (drafts listed, only live block, no edit_global_library needed); cursor counts shown entries only; divi/global-layout check via Divi module list; link hint dropped for internal types; divi-write-smoke roles-option restore same issue → U; scoped re-review dispatched +- Final fix re-review (opus): items 1–17 addressed (L1/L3/L4/L6/item 8 live-verified); F1 open: usage search misses Divi 4 shortcode global_module="ID" and JSON-escaped globalModule key/value (Divi renders them) → author edited an entry used only on such admin pages without dry run/P8a (live); minor: 'Eintrag, die' → 'den' +- Ruling P23: GlobalUsages finds references fail-closed: SQL prefilter candidates = content containing 'globalModule' OR 'global_module' OR a Divi block opener with '\\u' escapes; then PHP-parse candidates: Divi 4 shortcodes (global_module attr, intval) and Divi 5 openers (json_decode attrs → globalModule intval); an escaped opener whose globalModule can't be decoded counts as a possible reference (blocking if not editable); guide sentence adjusted; wp_block embedding documented as parity (Info) — cost if wrong: more pages listed/blocking on odd content +- Final fix round 1 (P23; fec5d9d, 8f87ec6; 1887 tests; smokes ok): candidate prefilter globalModule/global_module/escaped ASCII letter; UsageMatcher parses Divi 5 (json_decode) + Divi 4; undecodable escaped opener = possible reference; parse cap 5000 +- F1 re-check: live bypass fixed, prefilter not evadable; NEW G1 Divi 4 shortcode inside a Divi 5 attribute value (\u0022-escaped quotes) misparsed; G2 leading space / scientific notation cast by Divi (int); G3 global_module values casting to 0 render the NEWEST published layout (et_pb_load_global_module WP_Query p=0); _bin collation note +- Ruling P24: UsageMatcher scans decoded string values of every Divi 5 opener for Divi 4 shortcodes too; shortcode attrs parsed like WordPress (shortcode_parse_atts) and cast with (int) exactly like Divi; a global_module that casts to 0 counts as a reference to EVERY library entry (fail closed; rare); prefilter case-insensitive regardless of collation (LOWER() or explicit _ci COLLATE); guide wording without absolute claim; after this round remaining exotic forms are documented Low (breaker) — cost if wrong: coarse over-listing on odd content +- Final fix round 2 (P24; b6d791d; 1901 tests; smokes ok; live: shortcode in Divi 5 text value with ", leading space, abc, GLOBAL_MODULE found; other-ID control not found) +- Ruling P25 (breaker): accept after round 2 without another re-review — fail-closed rule in place, remaining exotic spellings need edit rights on the referencing page, documented as residual Low in the guide and rulings; G3 side effect (an invalid global_module on a real page makes every entry edit need confirm) accepted as fail-closed — cost if wrong: a crafted unusual spelling on a page the author can't edit escapes P8a (Low) +- Etappe 3a complete: cfc81dd..b6d791d diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md new file mode 100644 index 0000000..411de71 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md @@ -0,0 +1,210 @@ +# Etappe 3a (Divi-Bibliothek): security audit + +- **Scope:** branch etappe-3, cfc81dd..186f7af. Focus: what the per-task reviews covered least (object authorization, global edits, stored-content injection, disclosure, DoS). +- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1), single site, no editors/authors present, `DISALLOW_UNFILTERED_HTML` off. +- **Method:** read all new/changed `src/` files of the range plus the relevant Divi 5 render code (`BlockParser`, `BlockParserStore`, `GlobalLayout`, `DynamicContentOptions*`, `LinkUtils`, MultiView, Divi 4 `class-et-builder-element.php`), scanned all `script-library-*.js` for DOM sinks, and ran four self-cleaning `wp eval-file` probes (temp posts 1906–1914, temp user `litaudit_author`; all deleted, verified: 0 `LITAUDIT` posts, 0 postmeta/term rows for those IDs, no temp user, no probe files, `git status` clean). No [Fixture]/[Test] content was touched; no code was changed. + +**Result:** no Critical, High or Medium findings. The rulings P3/P5/P8a/P11/P12/P15–P19 hold. Six Low findings and three Info/hardening notes follow. + +--- + +## Findings (most severe first) + +### L1 – Low (DoS): the P8a check loads every live usage with full content, and each dry run scans `wp_posts` 3× + +**Where** +- `src/Integrations/Divi/Library/GlobalUsages.php:68-79` (`liveIds()`: no LIMIT). +- `src/Integrations/Divi/Library/GlobalEdits.php:158-171` (`_prime_post_caches($live, …)`, then `current_user_can('edit_post')` per ID). +- `GlobalUsages.php:29-47`: COUNT plus SELECT, each an unindexed `post_content LIKE '%…%'`. +- Also reached from `GlobalEdits::notice()` (every delete dry run) and `LibraryListAbility::detail()`. + +**Scenario** +1. A global module (newsletter box, CTA) sits in thousands of posts. That is a common Divi pattern. +2. Any user with library rights and `edit_post` on the entry calls a Divi tool on it without `confirm` (dry run), or `lit/library-list` with `id`. +3. `_prime_post_caches()` fetches `posts.*` for every live ID in one query, `post_content` included. At 5,000 posts × ~50 KB of Divi markup that is about 250 MB, which exceeds the usual `memory_limit` and ends the request in a fatal error. +4. Each call also runs 3 full-table LIKE scans (6 on confirm, because dry run and confirm both call `of()` and `blocked()`). A loop of calls ties up the database on large sites. +5. The effect stops when the requests stop, and it needs an authenticated MCP user. + +**Evidence:** by reading. The DDEV data set is too small to reproduce the memory limit. + +**Fix** +- Use one query: `SELECT ID, post_status, post_type, post_author, post_title … LIKE …`. Derive `count`, the first 50 items and the live list in PHP, instead of COUNT + SELECT + `liveIds()`. +- Evaluate `edit_post` in chunks of about 200 IDs, calling `_prime_post_caches()` per chunk and `clean_post_cache()` or `wp_cache_flush_runtime()` between chunks. +- Or fail closed: above a hard cap (e.g. 1,000 live usages), refuse the confirmed edit with a hint ("im Divi-Builder bzw. von einem Administrator bearbeiten"). + +### L2 – Low (global edits): "global" is decided by the scope term, but Divi renders every referenced entry, so edits to referenced non-global entries skip the dry run and P8a + +**Where** +- `src/Integrations/Divi/Library/GlobalEdits.php:71` and `:88-90`: `if (! $global) return LibraryItem`. +- `src/Integrations/Divi/Library/DiviLibraryStore.php:37` (`isGlobal()` → `GlobalLayout::is_global_layout_template()`, which checks the scope term, with a meta fallback for modules only). +- Divi: `BlockParser.php:838-880` renders any `divi/global-layout` whose `globalModule` names a *published `et_pb_layout`*. It checks only `post_type` and `post_status`, not the scope. + +**Scenario** +1. A live page that user U cannot edit holds an instance of library entry N, but N has no `global` scope term. This can happen when: + - the entry comes from an import (Divi's D4 fallback covers modules only, not sections or rows); + - the scope was changed later; + - the markup was written outside the Divi builder. +2. U has `edit_post` on N (as its author, or as an editor) and changes N with any Divi tool. +3. `GlobalEdits` treats N as a normal entry: no dry run, no `affected` list, no P8a check, no site-wide CSS clear. +4. The page U cannot edit now shows the changed content. + +**Evidence (live)** +- Temp entry 1910 (`scope = non_global`) and temp published page 1911 with ``. +- `isGlobal(1910) = false`, while `GlobalUsages::liveIds(1910) = [1911]`. +- The frontend HTTP render of 1911 shows the marker from 1910 (`LITAUDIT_NORMAL_ENTRY`). +- Both posts were deleted afterwards. + +**Fix:** in `GlobalEdits::prepare()` and `notice()`, apply the global rules (confirm, `affected`, P8a, `GlobalItem` scope) whenever `isGlobal()` is true **or** `GlobalUsages` finds at least one usage. For the check, one `LIMIT 1` query is enough. + +### L3 – Low (disclosure): the `lit/library-list` cursor is an oracle over the *content* of entries the caller may not read + +**Where** +- `src/Integrations/Divi/Library/LibraryListAbility.php:176-194`: rows the caller may not read still advance `$position`, and `next_cursor = Cursor::encode($position)` is `base64("o:N")`. +- `src/Integrations/Divi/Library/DiviLibraryStore.php:103`: `'s' => search` makes WP search title, excerpt **and content**, although the schema at `LibraryListAbility.php:68` says "Suche im Titel". + +**Scenario** +1. An author with the Divi library right creates two entries of their own that contain candidate words. +2. They then search with `limit: 1`. +3. The decoded cursor tells how many hidden entries match the search and sort before their own. That lets them guess words from other users' library content (codes, internal notes). +4. Plugin rule E3 / P12 is that authors may not read those entries: `lit/library-list id=…` returns `lit_forbidden`. +5. Mitigation: Divi's own Visual Builder library lists all entries to every user with the library right. Relative to Divi this is parity; relative to the plugin's own stricter rule it is a bypass. It is the same defect class that `PostGuard::ownPostsOnly()` already documents. + +**Evidence (live)** +- Temp author and 3 temp entries: an admin entry "aaa" containing `LITAUDITSECRET pin 4711`, and author entries "zzz1" and "zzz2" containing `LITAUDITSECRET`. +- As the author, `search=LITAUDITSECRET, limit=1` returns items `["LITAUDIT zzz1"]` with `next_cursor=bzoy` (= `o:2`, which reveals one hidden match). +- A direct read of the admin entry returns `lit_forbidden`. +- All temp data was deleted and the deletion verified. + +**Fix** +- Restrict the query in `find()`: + - add `'author' => PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE)` when it is non-null, so the cursor counts only rows the caller can see; + - set `'search_columns' => ['post_title']` (WP ≥ 6.2) to match the documented title-only search. +- Keep the per-row `canRead()` filter as defence in depth. + +### L4 – Low (disclosure): `affected`, `blocked_ids` and the P8a refusal expose IDs, status and type of posts the caller may not read + +**Where** +- `src/Integrations/Divi/Library/GlobalUsages.php:48-56`: title masked, but `post_id`, `status` and `type` are always returned. +- `src/Integrations/Divi/Library/GlobalEdits.php:184-203`: IDs in message and details. +- Also `lit/library-list id=…` (`affected`). + +**Scenario** +1. An author owns a global entry. An administrator used it on private pages and on other users' drafts. +2. The author's dry run lists those posts' IDs, their status (`private`, `draft`, `future`) and their post type. +3. Only the titles are hidden. + +**Evidence:** by reading. The masking applies to `post_title` only. + +**Fix** +- For rows without `read_post`, return only an aggregate: `hidden_count`, plus the count of blocking hidden posts. +- Name blocked IDs only where `read_post` holds; otherwise say "N Einträge, die dieses Konto nicht lesen darf". + +### L5 – Low (authorization): `lit/divi-node-copy` from a library entry into a page skips Divi's role option `add_library` + +**Where** +- `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:78-96`: the source goes through `DiviPageLoader::load(…, false)`, i.e. `LibraryAccess::item()` / `canRead()` (`edit_posts`, `divi_library`, `edit_post`). +- Compare `LibraryAccess::canInsert()` (`add_library`), which `lit/library-insert` requires. + +**Scenario** +1. The site owner turns off "Aus der Bibliothek hinzufügen" (add from library) for a role in Divi → Rollen-Editor. +2. A user of that role can still copy any node from an entry they can edit into a page, via `lit/divi-node-copy` with `source_post_id = `. +3. Security impact is small, because content still passes StoredNodeGuard. But it breaks E3 ("nie laxer als Divi", never laxer than Divi). + +**Evidence:** by reading. + +**Fix:** in `precheck()`, when the loaded source `isLibraryItem()`, additionally return `$access->canInsert()`. The target side is already covered by `canEdit()` / `save_library`. + +### L6 – Low (global edits): P8a accepts Theme Builder layouts on WordPress `edit_post` alone + +**Where** +- `src/Integrations/Divi/Library/GlobalEdits.php:158-171`. +- On DDEV, `et_header_layout`, `et_template`, `et_theme_builder` and `et_pb_layout` all use capability type `post` with `map_meta_cap`. So an editor has `edit_post` on the site header and footer layouts. +- Divi gates the Theme Builder with the role option `theme_builder`, which is off by default for every role but administrator. + +**Scenario** +1. A global module sits in the Theme Builder header, footer or body. +2. An editor with `edit_global_library` passes P8a, because they have `edit_post` on the TB layout. +3. The confirmed edit changes the site-wide header, although Divi does not let this editor use the Theme Builder. +4. Divi's own Visual Builder allows the same global edit from any page (parity with Divi). But P8a's intent, "changes only on posts this account may edit", is not met. + +**Evidence:** live, post type objects and caps (probe p1). The rest is by reading. + +**Fix:** in `blocked()`, also treat IDs whose post type is one of `et_header_layout`, `et_body_layout`, `et_footer_layout` or `et_template` as blocked unless `DiviRoles::allows('theme_builder')`. + +--- + +## Info / hardening + +### I1 – StoredNodeGuard trusts the instance's own `blockName` and `globalModule`; Divi renders the real entry + +**Where** +- `src/Integrations/Divi/Compose/StoredNodeGuard.php:90-91` and `:114-138`. Routing and the code-module check use `GlobalInstance::effectiveName()`, i.e. the stored `blockName`. +- `Json::decodeObject()`: the last duplicate key wins. + +**What Divi does** +- `BlockParser.php:838` takes the *first textual* `"globalModule":"(\d+)"`. That can be a nested one inside `localAttrs`, or the first of two duplicate keys. +- `BlockParserStore.php:1113ff` merges `localAttrs` (filtered by the entry's `localAttrsMap`) onto the entry's *actual* root block. + +**Consequence** +- A forged instance can be routed as `divi/text` (kses-checked richtext) while it is rendered onto a `divi/code` entry, or onto another entry than the guard saw. +- Exploiting this needs a forged instance in a source the user can edit. The user can already write that into their own page through core REST (parity, cf. 2b audit M1). The new part is that node-copy can carry it into a library entry (`et_pb_layout` has `show_in_rest = false`). + +**Hardening** +- Refuse instances whose JSON contains more than one `"globalModule"` occurrence. +- Resolve `globalModule` to a published `et_pb_layout` the user may read. +- Require its root block name to equal `blockName`, and route `localAttrs` by that real name. + +### I2 – Confirm is not bound to the dry run + +- `GlobalEdits.php:95-117`: between the dry run and `confirm: true`, the `affected` set can grow; the user agreed to N pages, and M are changed. +- Rights are safe, because P8a is recomputed at confirm (verified by reading; a live check exists from T9). +- Optional: accept `expected_affected_count` (or a hash of the affected IDs) and refuse on mismatch. + +### I3 – GlobalUsages misses non-canonical references Divi still renders + +- `"globalModule":"0123"` renders entry 123: Divi's `([0-9]+)` capture is cast to int by `get_post()`. +- The LIKE on `"globalModule":"123"` does not match it. +- Only someone who can edit the containing post can write it, so there is no attacker gain. +- Optional: LIKE on `'"globalModule":"%'` plus a PHP regex with `ltrim($id, '0')`. + +**Not a gap (checked live):** Divi 4 shortcode pages with `global_module=""`. GlobalUsages does not find them (probe p2/p3: `liveIds = []`), but Divi 5 does not render the D5 entry content there either. The frontend showed the page's own local D4 content, not the entry's. The plugin refuses to edit D4-format entries, so no unlisted page changes. + +--- + +## Areas checked and found sound + +- **IDOR via `library_id`:** `LibraryInsertAbility::item()` goes through `LibraryAccess::item(false)` (P5/P12): same `not_found` for non-library IDs and revisions, `forbidden` only for users with the library right. +- **IDOR via `source_post_id`:** PostGuard (`edit_post`, managed types only; `revision`, TB and library types excluded) or LibraryAccess, re-checked in `execute()`, with its own `source_tree_rev`. +- **IDOR via `post_id` in `$variable` settings / `{{link:ID}}`:** `select_post` → `read_post` (`DynamicContentRules.php:246-259`), re-checked by StoredNodeGuard through `canonical()` against the target post. Only `post_link_url_*` uses `select_post` in Divi 5.2.1. `{{link:}}` to `et_pb_layout` is refused by the registry, with a uniform message. +- **Divi role options:** + - `divi_library`, `save_library`, `add_library` and `edit_global_library` are combined correctly with `edit_posts`, `edit_post` and `publish_posts` in `LibraryAccess` (except L5). + - The P9 group gates (writable/visible) are applied in `item()`, `canEdit()`, `canSave()` and `canInsert()`. +- **Global edits through replace-text, node-copy into, insert into, and delete in a global entry:** + - All go through `DiviWriter::commit()` → `GlobalEdits` (shape, dry run, P8a, backup, `GlobalItem` scope). + - replace-text never sends `confirm`. + - An instance inside a global entry is refused (P11/`shapeViolation`). + - Generic WP tools (`content-update`, `post-trash`, `revision-restore`, `duplicate`) cannot reach `et_pb_layout` (not managed). +- **Dry run/confirm race for rights:** P8a is recomputed at confirm from a fresh query. The `tree_rev` lock covers entry content changes. +- **Content injection:** + - `localAttrs` and the snapshot children of instances are walked, and so are local children, because the parser keeps inner blocks of opaque blocks. + - `deglobalize` runs before the guard. + - CRLF unification touches whitespace-only text between blocks only. + - Invalid or non-`divi/` `blockName` instances are refused as opaque. + - Library titles go through `sanitize_text_field` (plus core title kses). + - Terms are limited to existing names. +- **Divi frontend sinks beyond interactions and Lottie** (scan of all `script-library-*.js`): + - module link → `diviElementLinkData`, where `LinkUtils::generate_data` applies `esc_url` before `window.location`/`window.open`; + - multi-view `innerHTML`/`setAttribute`: data via `wp_localize_script` (nested values are not entity-decoded), with fixed attribute names and per-attribute sanitizers; + - `new Function` hits are bundler global shims; + - magnific-popup, slider and menu read `src`/`href` from escaped markup. + - Together with the T11 live URL matrix, no new sink was found. +- **Disclosure:** + - `dynamic-sources` samples only an editable post, uses the `edit` context (custom meta hidden without `read_dynamic_content_custom_fields`) and returns no option defaults. + - Render-summary notes list only IDs already present in content the caller may read. + - site-status `problems` are labels of missing Divi internals only. + - Error texts are uniform for missing and unreadable IDs (P12; `{{link:}}` from T2). The known P13 leftovers are left to the final wave. +- **Multisite:** everything is per-blog (`$wpdb->posts`, Divi role options, `current_user_can`). No network-level path exists. Without `unfiltered_html` (site admins), the guard and the P9 backup refusal only get stricter. +- **DoS (other):** + - `lit/library-list` scan is capped (`MAX_SCAN` 500, `limit` ≤ `Cursor::MAX_LIMIT`). + - Guide `topic=all` is capped at 100 KB of static files. + - `affected` is capped at 50 rows (the cost is in L1, not the output).