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)]);