diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 6fb9c54..a5a35ee 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -17,7 +17,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist (Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen - können trotzdem unerkannt bleiben. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint + können trotzdem unerkannt bleiben; dazu gehört ein maskiert in einem Modulwert gespeicherter + `divi/global-layout`-Block: Divi wertet Blöcke in der Ausgabe von Shortcode-Modulen und in + WooCommerce-Textmodulen (Warenkorb, Kasse) erneut aus, sodass er dort als globales Modul erscheinen kann + (bekanntes Restrisiko, wie die übrigen ungewöhnlichen Schreibweisen; es braucht Bearbeitungsrechte auf der + verweisenden Seite). Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst. - `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. diff --git a/src/Integrations/Divi/BlockTree/Fingerprint.php b/src/Integrations/Divi/BlockTree/Fingerprint.php index df30301..c468e0c 100644 --- a/src/Integrations/Divi/BlockTree/Fingerprint.php +++ b/src/Integrations/Divi/BlockTree/Fingerprint.php @@ -12,8 +12,10 @@ defined('ABSPATH') || exit; * Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and * lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two * sections built alike with other texts share it. content(): every attribute but the admin label, - * builderVersion and globalParent, children included – "the same section". Whitespace between blocks never - * counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP. + * builderVersion and globalParent, children and every non-whitespace text part included – "the same section" + * (ruling Q25: raw HTML between blocks and the inner HTML of core blocks are visible too). Whitespace between + * blocks never counts; an opaque block counts with all its bytes (content) or by its effective type (structure). + * Pure PHP. */ final class Fingerprint { @@ -30,6 +32,17 @@ final class Fingerprint return self::hash(self::body($block)); } + /** + * The normalized bytes content() hashes (ruling Q25): block names, attributes with sorted keys (without admin + * label, builderVersion, globalParent), every non-whitespace text part – raw HTML or shortcodes between blocks + * and the inner HTML of core blocks – and opaque blocks with all their bytes. Two nodes with the same + * canonical() look the same to visitors; lit/library-make-global compares these bytes, not only the hash. + */ + public static function canonical(Block $block): string + { + return self::body($block); + } + private static function shape(Block $block): string { if ($block->isOpaque()) { @@ -47,7 +60,7 @@ final class Fingerprint private static function body(Block $block): string { if ($block->isOpaque()) { - return 'opaque:' . (string) $block->rawOpen(); + return 'opaque:' . self::text(Serializer::block($block)); } $attrs = $block->attrs(); @@ -57,9 +70,22 @@ final class Fingerprint if (is_array($attrs['module']['meta'] ?? null)) { unset($attrs['module']['meta']['adminLabel']); } - $children = array_map(self::body(...), $block->children()); + $parts = []; + foreach ($block->isVoid() ? [] : $block->parts() as $part) { + if ($part instanceof Block) { + $parts[] = self::body($part); + } elseif (trim($part) !== '') { + $parts[] = 'text:' . Json::encode(self::text($part)); + } + } - return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']'; + return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $parts) . ']'; + } + + /** Text as visitors get it: line breaks unified, whitespace at both ends dropped. */ + private static function text(string $text): string + { + return trim(str_replace("\r\n", "\n", $text)); } /** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */ diff --git a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php index 32651bd..8939a4d 100644 --- a/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php +++ b/src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php @@ -40,17 +40,19 @@ defined('ABSPATH') || exit; * instance the Visual Builder writes (GlobalInstance::of). All or nothing: the page is written through * DiviWriter::commit() (tree_rev lock, revision); when it is refused or rolled back, the new entry is deleted * again (NewDraft) – only a page write that may have been saved (partial) keeps the entry, because the page may - * already show it. Sources are pages and posts only: no library entries (no nesting, ruling P11) and no Theme - * Builder layouts (controller ruling; there lit/library-save and lit-library-insert). Needs the rights of + * already show it. Sources are pages and posts only (post types page and post, ruling Q25): no library entries + * (no nesting, ruling P11), no Theme Builder layouts (ruling Q24; there lit/library-save and lit-library-insert) + * and no other Divi-enabled types. Needs the rights of * lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2). * * Second mode (E10, answer F2): library_id plus targets replaces further occurrences – only nodes whose content - * fingerprint equals the entry's root ("identical" in lit/library-find-similar) – by instances of that active + * fingerprint and normalized bytes (Fingerprint::canonical, ruling Q25: visible text parts included) equal the + * entry's root ("identical" in lit/library-find-similar) – by instances of that active * global entry (one readable root, no reference to any entry in any spelling, ruling Q7). A global edit: every * target is checked first (edit_post and tree_rev through DiviWriter::page(), P8a – the account changes only pages - * it may edit; identical nodes; the instance passes the page's StoredNodeGuard; pages and posts only, no library - * entries or Theme Builder layouts), then a dry run lists the pages (tree_rev, status, live – drafts are listed, - * like P8a), and only confirm: true writes. Every page goes through DiviWriter::commit() with its own tree_rev + * it may edit; identical nodes; the instance passes the page's StoredNodeGuard; post types page and post only), + * then a dry run lists the pages (tree_rev, status, live – drafts are listed, like P8a), and only confirm: true + * writes. Every page goes through DiviWriter::commit() with its own tree_rev * lock, revision and change-log row; a live page (publish, private, future) without revisions first gets * VisibleEdits' backup, refused for every page before anything is written where it could not be faithful (P9). * Pages are written in the given order; when page N fails, pages 1..N-1 stay replaced (each with its restore @@ -68,6 +70,9 @@ final class LibraryMakeGlobalAbility implements Ability private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul']; + /** Post types that are sources and targets (ruling Q25): pages and posts, no other Divi-enabled type. */ + public const POST_TYPES = ['page', 'post']; + /** Inputs of the first mode, refused next to library_id. */ private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags']; @@ -102,8 +107,9 @@ final class LibraryMakeGlobalAbility implements Ability public function description(): string { return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder ' - . 'in einem Beitrag in ein globales Modul um: legt einen globalen Bibliothekseintrag mit genau diesem ' - . 'Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – alles oder nichts. Danach zeigt die ' + . 'in einem Beitrag (Inhaltstypen page und post) in ein globales Modul um: legt einen globalen ' + . 'Bibliothekseintrag mit genau diesem Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – ' + . 'alles oder nichts. Danach zeigt die ' . 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.' . ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids ' . 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"; nur Seiten und Beiträge); das ' @@ -370,6 +376,7 @@ final class LibraryMakeGlobalAbility implements Ability } $fingerprint = Fingerprint::content($root); + $canonical = Fingerprint::canonical($root); $plans = []; $errors = []; $seen = []; @@ -383,9 +390,9 @@ final class LibraryMakeGlobalAbility implements Ability return $page; } $postId = (int) $page->post->ID; - if ($page->isLibraryItem() || $page->isThemeBuilderLayout() || isset($seen[$postId])) { - $errors["targets.$i.post_id"] = 'nur Seiten und Beiträge, jede einmal (Theme-Builder-Layouts und ' - . 'Bibliothekseinträge einzeln mit lit-library-insert)'; + if (self::sourceRefusal($page) !== null || isset($seen[$postId])) { + $errors["targets.$i.post_id"] = 'nur Seiten und Beiträge (page, post), jede einmal (andere Einträge ' + . 'einzeln mit lit-library-insert)'; continue; } $seen[$postId] = true; @@ -397,7 +404,10 @@ final class LibraryMakeGlobalAbility implements Ability $nodes = []; foreach ($ids as $j => $id) { $node = is_string($id) ? $page->index->find($id) : null; - if ($node === null || $node->isOpaque() || Fingerprint::content($node) !== $fingerprint) { + // The hash first (cheap), then the normalized bytes themselves (ruling Q25) – no collision counts. + $identical = $node !== null && ! $node->isOpaque() && Fingerprint::content($node) === $fingerprint + && Fingerprint::canonical($node) === $canonical; + if (! $identical) { $errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" ' . 'aus lit/library-find-similar)'; continue; @@ -512,9 +522,11 @@ final class LibraryMakeGlobalAbility implements Ability return $e->withDetails($details); } + // DiviSaver's own state stays (e.g. "Revision X bleibt erhalten"). $state = sprintf( - 'Seite %d wurde nicht geändert. Bereits ersetzt (je mit Revision): %s; unverändert: %s.', + 'Seite %d: %s Bereits ersetzt (je mit Revision): %s; unverändert: %s.', $postId, + $e->state ?? 'Die Änderung wurde nicht gespeichert.', implode(', ', $done), $rest === [] ? 'keine weiteren' : implode(', ', $rest) ); @@ -579,8 +591,9 @@ final class LibraryMakeGlobalAbility implements Ability } /** - * Only pages and posts: a library entry would nest global modules (P11), a Theme Builder layout is left to - * lit/library-save plus lit-library-insert (controller ruling on the plan's open point). + * Only pages and posts (POST_TYPES, ruling Q25): a library entry would nest global modules (P11), a Theme + * Builder layout is left to lit/library-save plus lit-library-insert (ruling Q24), other Divi-enabled types + * are out of scope. */ private static function sourceRefusal(DiviPage $page): ?WP_Error { @@ -600,6 +613,13 @@ final class LibraryMakeGlobalAbility implements Ability ); } + if (! in_array((string) $page->post->post_type, self::POST_TYPES, true)) { + return Errors::invalidInput( + 'lit/library-make-global arbeitet nur auf Seiten und Beiträgen; nichts wurde gespeichert.', + ['post_id' => 'eine Seite oder ein Beitrag (Inhaltstyp page oder post)'] + ); + } + return null; } diff --git a/tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php b/tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php index 4c44c4c..04551bf 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php @@ -68,4 +68,68 @@ final class FingerprintTest extends TestCase self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b)); self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c)); } + + /** The section of 1055 with $extra inserted at the end of its column (review of Task 15, ruling Q25). */ + private static function sectionWith(string $extra): Block + { + $markup = DiviFixtures::markup('stage3/library--library-abschnitt.html'); + $at = strrpos($markup, ''); + $markup = substr($markup, 0, (int) $at) . $extra . substr($markup, (int) $at); + + return LibraryContent::roots(LibraryContent::load(new Parser(), $markup))[0]; + } + + /** @return array */ + public static function visibleExtras(): array + { + return [ + 'raw html between blocks' => ['

ONLY ON B

', '

ONLY ON C

'], + 'core paragraph' => [ + '

ONLY ON B

', + '

ONLY ON C

', + ], + 'divi 4 shortcode text' => ['[et_pb_text]ONLY ON B[/et_pb_text]', '[et_pb_text]ONLY ON C[/et_pb_text]'], + ]; + } + + /** + * Ruling Q25: text parts are visible – raw HTML between blocks and the inner HTML of core blocks count + * for the content, like attributes. + * + * @dataProvider visibleExtras + */ + public function testVisibleTextPartsCountForTheContent(string $extra, string $other): void + { + $plain = self::section(); + + self::assertNotSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith($extra))); + self::assertNotSame( + Fingerprint::content(self::sectionWith($extra)), + Fingerprint::content(self::sectionWith($other)) + ); + self::assertNotSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith($extra))); + } + + public function testRawTextKeepsTheStructureAndWhitespaceNeverCounts(): void + { + $plain = self::section(); + + self::assertSame(Fingerprint::structure($plain), Fingerprint::structure(self::sectionWith('

x

'))); + self::assertSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith("\n \n"))); + self::assertSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith("\n\t"))); + } + + /** Opaque blocks count with all their bytes (a global instance's snapshot included). */ + public function testOpaqueBlocksCountWithAllTheirBytes(): void + { + $instance = static fn (string $text): Block => LibraryContent::roots(LibraryContent::load( + new Parser(), + '' + . '

' . $text + . '

' + . '' + ))[0]; + + self::assertNotSame(Fingerprint::content($instance('a')), Fingerprint::content($instance('b'))); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php index bbbc3f4..23fe3b4 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php @@ -212,4 +212,33 @@ final class LibraryFindSimilarAbilityTest extends TestCase self::assertCount(5, $rest['matches']); self::assertArrayNotHasKey('note', $rest); } + + /** Ruling Q25: raw HTML next to the blocks is visible – such a copy is only "structure", never "identical". */ + public function testExtraVisibleTextIsNotIdentical(): void + { + $root = LibraryContent::roots(LibraryContent::load( + new Parser(), + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ))[0]; + $copy = Serializer::block($root); + $at = strrpos($copy, ''); + $changed = substr($copy, 0, (int) $at) . '

ONLY ON 8

' . substr($copy, (int) $at); + LibraryFixtures::given([ + LibraryFixtures::item(1055), + new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Über uns', + 'post_content' => '' . $changed . '']), + ]); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [ + 'edit_posts' => 'edit_posts', + 'edit_others_posts' => 'edit_others_posts', + ]]); + Functions\when('get_posts')->justReturn([8]); + + $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); + + self::assertSame(['8 s0 structure'], array_map( + static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], + $result['matches'] + )); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php index 74a979c..bd23085 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php @@ -490,4 +490,78 @@ final class LibraryMakeGlobalAbilityTest extends TestCase } self::assertSame([], $this->saved); } + + /** @return array */ + public static function visibleExtras(): array + { + return [ + 'core paragraph' => ['

ONLY ON 8

'], + 'raw text part' => ['

ONLY ON 8

'], + ]; + } + + /** + * Ruling Q25 (review of Task 15): a copy with extra visible text – a core paragraph or raw HTML between + * blocks – is no identical occurrence; replacing it would swap visible content. + * + * @dataProvider visibleExtras + */ + public function testACopyWithExtraVisibleTextIsNoIdenticalOccurrence(string $extra): void + { + $copy = $this->givenCopiesOf1058(); + $at = strrpos($copy, ''); + $changed = substr($copy, 0, (int) $at) . $extra . substr($copy, (int) $at); + LibraryFixtures::given([ + LibraryFixtures::item(1058), + new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team', + 'post_content' => '' . $changed . '']), + get_post(9), + ]); + + $result = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('targets.0.ids.0', self::errorFields($result)); + self::assertSame([], $this->saved); + } + + /** Scope (ruling Q25): only pages and posts – other Divi-enabled types are neither targets nor sources. */ + public function testOnlyPagesAndPostsAreTargetsAndSources(): void + { + $this->givenCopiesOf1058(); + $project = get_post(8); + $project->post_type = 'project'; + LibraryFixtures::given([LibraryFixtures::item(1058), $project, get_post(9)]); + + $target = $this->ability()->execute(self::targets(['s0'], ['s1'], true)); + self::assertToolError($target, 'validation_failed'); + self::assertArrayHasKey('targets.0.post_id', self::errorFields($target)); + + $source = $this->ability()->execute( + ['post_id' => 8, 'tree_rev' => Rev::of((string) $project->post_content), 'id' => 's0', 'title' => 'x'] + ); + self::assertToolError($source, 'validation_failed'); + self::assertArrayHasKey('post_id', self::errorFields($source)); + self::assertSame([], $this->saved); + self::assertSame([], $this->specs); + } + + /** A rolled-back page N>1 keeps DiviSaver's note on the revision that stays. */ + public function testAFailureMidwayKeepsTheRevisionNote(): void + { + $this->givenCopiesOf1058(); + $kept = WriteFailed::rolledBack( + 'Die Divi-Seite konnte nicht gespeichert werden.', + state: \LIT\MCP\Abilities\Errors::notSavedKeeping('Revision 812') + ); + + try { + $this->ability([], true, false, [null, $kept])->execute(self::targets(['s0'], ['s1'], true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + $message = (string) $e->toError()->get_error_message(); + self::assertStringContainsString('Revision 812 bleibt erhalten', $message); + self::assertStringContainsString('Bereits ersetzt', $message); + } + } }