fix: identical means identical bytes – text parts count, pages and posts only (ruling Q25)

- Fingerprint::content() includes non-whitespace text parts (raw HTML between
  blocks, inner HTML of core blocks) and opaque blocks with all their bytes;
  structure() unchanged; find-similar "identical" follows
- make-global mode 2 also compares Fingerprint::canonical() bytes of each node
- sources and targets: post types page and post only (POST_TYPES)
- a rolled-back page N>1 keeps DiviSaver's "Revision X bleibt erhalten"
- guide: escaped divi/global-layout re-expanded by Divi's do_blocks in
  shortcode-module and WooCommerce text output documented as residual risk

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 21:59:20 +02:00
co-authored by Claude Opus 5.5
parent e733065b60
commit 4e959833b9
6 changed files with 238 additions and 21 deletions
+5 -1
View File
@@ -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.
@@ -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. */
@@ -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;
}
@@ -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, '<!-- /wp:divi/column -->');
$markup = substr($markup, 0, (int) $at) . $extra . substr($markup, (int) $at);
return LibraryContent::roots(LibraryContent::load(new Parser(), $markup))[0];
}
/** @return array<string, array{string, string}> */
public static function visibleExtras(): array
{
return [
'raw html between blocks' => ['<p>ONLY ON B</p>', '<p>ONLY ON C</p>'],
'core paragraph' => [
'<!-- wp:paragraph --><p>ONLY ON B</p><!-- /wp:paragraph -->',
'<!-- wp:paragraph --><p>ONLY ON C</p><!-- /wp:paragraph -->',
],
'divi 4 shortcode text' => ['[et_pb_text]ONLY ON B[/et_pb_text]', '[et_pb_text]ONLY ON C[/et_pb_text]'],
];
}
/**
* Ruling Q25: text parts are visible – raw HTML between blocks and the inner HTML of core blocks count
* for the content, like attributes.
*
* @dataProvider visibleExtras
*/
public function testVisibleTextPartsCountForTheContent(string $extra, string $other): void
{
$plain = self::section();
self::assertNotSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith($extra)));
self::assertNotSame(
Fingerprint::content(self::sectionWith($extra)),
Fingerprint::content(self::sectionWith($other))
);
self::assertNotSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith($extra)));
}
public function testRawTextKeepsTheStructureAndWhitespaceNeverCounts(): void
{
$plain = self::section();
self::assertSame(Fingerprint::structure($plain), Fingerprint::structure(self::sectionWith('<p>x</p>')));
self::assertSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith("\n \n")));
self::assertSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith("\n\t")));
}
/** Opaque blocks count with all their bytes (a global instance's snapshot included). */
public function testOpaqueBlocksCountWithAllTheirBytes(): void
{
$instance = static fn (string $text): Block => LibraryContent::roots(LibraryContent::load(
new Parser(),
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text"} --><p>' . $text
. '</p><!-- /wp:divi/global-layout -->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
))[0];
self::assertNotSame(Fingerprint::content($instance('a')), Fingerprint::content($instance('b')));
}
}
@@ -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, '<!-- /wp:divi/column -->');
$changed = substr($copy, 0, (int) $at) . '<p>ONLY ON 8</p>' . 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' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
]);
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']
));
}
}
@@ -490,4 +490,78 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
}
self::assertSame([], $this->saved);
}
/** @return array<string, array{string}> */
public static function visibleExtras(): array
{
return [
'core paragraph' => ['<!-- wp:paragraph --><p>ONLY ON 8</p><!-- /wp:paragraph -->'],
'raw text part' => ['<p>ONLY ON 8</p>'],
];
}
/**
* 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, '<!-- /wp:divi/column -->');
$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' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
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);
}
}
}