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
@@ -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;
}