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) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:14:58 +02:00
co-authored by Claude Opus 5.5
parent 8aacc5dc4f
commit 395049ec97
6 changed files with 117 additions and 36 deletions
+6 -4
View File
@@ -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` - `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (`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`: (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 ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die ihn als globales Modul
ihn zeigen (`affected`, `affected_count`). zeigen (`affected`, `affected_count`, `hidden_count`).
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen - `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. 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` 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 (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. 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. 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 `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. 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 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). `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`; `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` Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update`
und `confirm: true` vornehmen. und `confirm: true` vornehmen.
+36 -20
View File
@@ -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 * 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: * change validated and before anything is saved:
* - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type; * - 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 * - a global entry – and any entry that pages show as an instance, global scope or not (audit L2:
* saved and the answer lists those pages (a dry run of the fully validated change); with it the * 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 * save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
* copy is made first – the revision itself is DiviSaver's restore point, as for every page. * copy is made first – the revision itself is DiviSaver's restore point, as for every page.
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may * 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]); 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' => []]; return ['scope' => SaveScope::LibraryItem, 'details' => []];
} }
$report = $this->usages->report($id);
if (($input['confirm'] ?? false) !== true) { if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired( return Errors::confirmRequired(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' '%s: die Änderung wirkt sofort auf %d %s (siehe affected), auch veröffentlichte. Nichts wurde '
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', . 'gespeichert (Probelauf).',
$id, self::label($id, $global),
$report->count, $report->count,
self::pages($report->count) self::pages($report->count)
) . self::hiddenNote($report) . self::blockedNote($report), ) . 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) { if ($refusal !== null) {
return $refusal; 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 * A tool's own confirm_required (lit-divi-delete) on a global or referenced entry (audit L2),
* pages – so the one confirm the client sends covers both questions. * 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 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; return $error;
} }
$global = $this->access->isGlobal($page->post);
$report = $this->usages->report((int) $page->post->ID); $report = $this->usages->report((int) $page->post->ID);
if (! $global && ! $report->exists()) {
return $error;
}
$data = $error->get_error_data(); $data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
return Errors::confirmRequired( return Errors::confirmRequired(
Errors::clientMessage($error) Errors::clientMessage($error)
. sprintf( . 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, $report->count,
self::pages($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 * 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). * 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) { if ($report->tooMany) {
return Errors::unsupported( return Errors::unsupported(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d erscheint auf %d veröffentlichten, privaten oder geplanten ' '%s erscheint auf %d veröffentlichten, privaten oder geplanten Einträgen – mehr als %d; so große '
. 'Einträgen – mehr als %d; so große Änderungen nimmt LIT MCP nicht vor. Nichts wurde ' . 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.',
. 'gespeichert.', $label,
$id,
$report->live, $report->live,
GlobalUsages::HARD_CAP GlobalUsages::HARD_CAP
), ),
@@ -171,9 +179,9 @@ final class GlobalEdits
return Errors::forbidden( return Errors::forbidden(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, ' '%s erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto nicht '
. 'die dieses Konto nicht bearbeiten darf (%s); nichts wurde gespeichert.', . 'bearbeiten darf (%s); nichts wurde gespeichert.',
$id, $label,
self::blockedList($report) self::blockedList($report)
), ),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' '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) : ''); . ($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 private static function pages(int $count): string
{ {
return $count === 1 ? 'Seite' : 'Seiten'; return $count === 1 ? 'Seite' : 'Seiten';
@@ -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 * 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, * 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; * the pages that show it as an instance (global entries always, others when any page does, L2).
* every listed entry also edit_post – others are left out, without title or count). With id, * 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). * everything but a library entry the user may read answers like a missing ID (ruling P12).
*/ */
final class LibraryListAbility implements Ability 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), ' 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 ' . '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.'; . '(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), 'rev' => Rev::ofPost($page->post),
'edit_url' => $page->editUrl(), 'edit_url' => $page->editUrl(),
]; ];
if ($item['scope'] === 'global') { // Audit L2: a normal entry that pages show as an instance names them too.
$result += $this->usages->report((int) $page->post->ID, false)->listing(); $report = $this->usages->report((int) $page->post->ID, false);
if ($item['scope'] === 'global' || $report->exists()) {
$result += $report->listing();
} }
return $result; return $result;
+3 -3
View File
@@ -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. * are the real ones, with the post-cache functions stubbed.
* *
* @param list<array{id: int, status: string, type: string}>|\Closure(): list<array{id: int, status: string, type: string}> $rows * @param list<array{id: int, status: string, type: string}>|\Closure(int): list<array{id: int, status: string, type: string}> $rows
*/ */
public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages
{ {
@@ -124,7 +124,7 @@ final class LibraryFixtures
Functions\when('_prime_post_caches')->justReturn(null); Functions\when('_prime_post_caches')->justReturn(null);
Functions\when('wp_cache_delete')->justReturn(true); Functions\when('wp_cache_delete')->justReturn(true);
$usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial(); $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->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; return $usages;
} }
@@ -50,10 +50,17 @@ final class GlobalEditsTest extends TestCase
Functions\when('_prime_post_caches')->justReturn(null); Functions\when('_prime_post_caches')->justReturn(null);
} }
/** The live posts ($this->live; 1053 published, the others private) and draft 1070. */ /** @var list<int> 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 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( array_map(
static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'], static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'],
$this->live $this->live
@@ -133,6 +140,45 @@ final class GlobalEditsTest extends TestCase
self::assertArrayNotHasKey('affected', $result); 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 public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1059)]); LibraryFixtures::given([LibraryFixtures::item(1059)]);
@@ -38,7 +38,9 @@ final class LibraryListAbilityTest extends TestCase
)); ));
$access = LibraryFixtures::access($deniedRoles, true, $store); $access = LibraryFixtures::access($deniedRoles, true, $store);
$usages = LibraryFixtures::usages( $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); 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'); 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 public function testDetailOfANormalEntryHasNoAffectedPages(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1055)]); LibraryFixtures::given([LibraryFixtures::item(1055)]);