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:
@@ -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
|
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
|
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
|
(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.
|
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
|
- `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.
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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,
|
* 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
|
* builderVersion and globalParent, children and every non-whitespace text part included – "the same section"
|
||||||
* counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP.
|
* (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
|
final class Fingerprint
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,17 @@ final class Fingerprint
|
|||||||
return self::hash(self::body($block));
|
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
|
private static function shape(Block $block): string
|
||||||
{
|
{
|
||||||
if ($block->isOpaque()) {
|
if ($block->isOpaque()) {
|
||||||
@@ -47,7 +60,7 @@ final class Fingerprint
|
|||||||
private static function body(Block $block): string
|
private static function body(Block $block): string
|
||||||
{
|
{
|
||||||
if ($block->isOpaque()) {
|
if ($block->isOpaque()) {
|
||||||
return 'opaque:' . (string) $block->rawOpen();
|
return 'opaque:' . self::text(Serializer::block($block));
|
||||||
}
|
}
|
||||||
|
|
||||||
$attrs = $block->attrs();
|
$attrs = $block->attrs();
|
||||||
@@ -57,9 +70,22 @@ final class Fingerprint
|
|||||||
if (is_array($attrs['module']['meta'] ?? null)) {
|
if (is_array($attrs['module']['meta'] ?? null)) {
|
||||||
unset($attrs['module']['meta']['adminLabel']);
|
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. */
|
/** 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
|
* 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
|
* 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
|
* 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
|
* already show it. Sources are pages and posts only (post types page and post, ruling Q25): no library entries
|
||||||
* Builder layouts (controller ruling; there lit/library-save and lit-library-insert). Needs the rights of
|
* (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).
|
* 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
|
* 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
|
* 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
|
* 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
|
* it may edit; identical nodes; the instance passes the page's StoredNodeGuard; post types page and post only),
|
||||||
* entries or Theme Builder layouts), then a dry run lists the pages (tree_rev, status, live – drafts are listed,
|
* then a dry run lists the pages (tree_rev, status, live – drafts are listed, like P8a), and only confirm: true
|
||||||
* like P8a), and only confirm: true writes. Every page goes through DiviWriter::commit() with its own tree_rev
|
* 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
|
* 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).
|
* 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
|
* 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'];
|
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. */
|
/** Inputs of the first mode, refused next to library_id. */
|
||||||
private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags'];
|
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
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder '
|
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 '
|
. 'in einem Beitrag (Inhaltstypen page und post) in ein globales Modul um: legt einen globalen '
|
||||||
. 'Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – alles oder nichts. Danach zeigt die '
|
. '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.'
|
. '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 '
|
. ' 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 '
|
. '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);
|
$fingerprint = Fingerprint::content($root);
|
||||||
|
$canonical = Fingerprint::canonical($root);
|
||||||
$plans = [];
|
$plans = [];
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
@@ -383,9 +390,9 @@ final class LibraryMakeGlobalAbility implements Ability
|
|||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
$postId = (int) $page->post->ID;
|
$postId = (int) $page->post->ID;
|
||||||
if ($page->isLibraryItem() || $page->isThemeBuilderLayout() || isset($seen[$postId])) {
|
if (self::sourceRefusal($page) !== null || isset($seen[$postId])) {
|
||||||
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge, jede einmal (Theme-Builder-Layouts und '
|
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge (page, post), jede einmal (andere Einträge '
|
||||||
. 'Bibliothekseinträge einzeln mit lit-library-insert)';
|
. 'einzeln mit lit-library-insert)';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$seen[$postId] = true;
|
$seen[$postId] = true;
|
||||||
@@ -397,7 +404,10 @@ final class LibraryMakeGlobalAbility implements Ability
|
|||||||
$nodes = [];
|
$nodes = [];
|
||||||
foreach ($ids as $j => $id) {
|
foreach ($ids as $j => $id) {
|
||||||
$node = is_string($id) ? $page->index->find($id) : null;
|
$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" '
|
$errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" '
|
||||||
. 'aus lit/library-find-similar)';
|
. 'aus lit/library-find-similar)';
|
||||||
continue;
|
continue;
|
||||||
@@ -512,9 +522,11 @@ final class LibraryMakeGlobalAbility implements Ability
|
|||||||
return $e->withDetails($details);
|
return $e->withDetails($details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiviSaver's own state stays (e.g. "Revision X bleibt erhalten").
|
||||||
$state = sprintf(
|
$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,
|
$postId,
|
||||||
|
$e->state ?? 'Die Änderung wurde nicht gespeichert.',
|
||||||
implode(', ', $done),
|
implode(', ', $done),
|
||||||
$rest === [] ? 'keine weiteren' : implode(', ', $rest)
|
$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
|
* Only pages and posts (POST_TYPES, ruling Q25): a library entry would nest global modules (P11), a Theme
|
||||||
* lit/library-save plus lit-library-insert (controller ruling on the plan's open point).
|
* 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
|
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;
|
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($b));
|
||||||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
|
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::assertCount(5, $rest['matches']);
|
||||||
self::assertArrayNotHasKey('note', $rest);
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user