fix: Umschreiber nennt Bibliotheks-IDs globaler Instanzen nur bei sichtbaren Bibliotheks-IDs (P13)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 17:04:30 +02:00
co-authored by Claude Opus 5.5
parent d7a991d130
commit 65a207488e
4 changed files with 72 additions and 14 deletions
@@ -40,9 +40,10 @@ defined('ABSPATH') || exit;
* that is dropped afterwards (audit L1). * that is dropped afterwards (audit L1).
* - Skipped (named, never written): posts loadWritable() refuses (rights, Divi 4, unparsable markup – R8: not * - Skipped (named, never written): posts loadWritable() refuses (rights, Divi 4, unparsable markup – R8: not
* remaining, so repeated calls end), global instances the change concerns (their library entry is a usage of its * remaining, so repeated calls end), global instances the change concerns (their library entry is a usage of its
* own), library entries shown on live posts the caller may not edit (P8a), and library entries or Theme Builder * own; its ID is named only when DiviPageLoader::libraryIdsVisible(), ruling P13), library entries shown on
* layouts visible site-wide without revisions (no restore point; a bulk write makes no backup copies). Posts the * live posts the caller may not edit (P8a), and library entries or Theme Builder layouts visible site-wide
* caller may not read are never named, only counted (hidden_count, audit L4). * without revisions (no restore point; a bulk write makes no backup copies). Posts the caller may not read are
* never named, only counted (hidden_count, audit L4).
* - Save scope as in DiviWriter. The rewrite changes attributes only, so a library entry keeps its shape * - Save scope as in DiviWriter. The rewrite changes attributes only, so a library entry keeps its shape
* (LibraryContent::shapeViolation() needs no check). * (LibraryContent::shapeViolation() needs no check).
* - A failed save after other posts were saved answers partial_write with data.saved; a failure before the first * - A failed save after other posts were saved answers partial_write with data.saved; a failure before the first
@@ -232,7 +233,7 @@ class PageRewriter
return null; return null;
} }
$result = (new TreeRewrite($rewrite()))->apply($page->index); $result = (new TreeRewrite($rewrite(), $this->loader->libraryIdsVisible()))->apply($page->index);
foreach ($result['skipped'] as $node) { foreach ($result['skipped'] as $node) {
$skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']]; $skipped[] = ['post_id' => $id, 'reason' => $node['id'] . ': ' . $node['reason']];
} }
+9 -5
View File
@@ -14,11 +14,13 @@ defined('ABSPATH') || exit;
* Applies a NodeRewrite to every block of a NodeIndex, in place (ruling R8): readable blocks are offered to * Applies a NodeRewrite to every block of a NodeIndex, in place (ruling R8): readable blocks are offered to
* rewrite(), a changed one gets its new attributes and then its notes; opaque blocks – global instances, blocks with * rewrite(), a changed one gets its new attributes and then its notes; opaque blocks – global instances, blocks with
* unreadable attributes – are never offered and never changed, but listed as skipped when the strategy says the * unreadable attributes – are never offered and never changed, but listed as skipped when the strategy says the
* change concerns them (a global module is edited in its library entry). Pure PHP. * change concerns them (a global module is edited in its library entry). The reason names the library entry's ID
* only with $revealLibraryIds (ruling P13: the caller may see library IDs); otherwise it points to the Divi Builder
* (fail closed). Pure PHP.
*/ */
final class TreeRewrite final class TreeRewrite
{ {
public function __construct(private NodeRewrite $rewrite) public function __construct(private NodeRewrite $rewrite, private bool $revealLibraryIds = false)
{ {
} }
@@ -37,7 +39,7 @@ final class TreeRewrite
if ($block->isOpaque()) { if ($block->isOpaque()) {
if ($this->rewrite->concernsInstance($block)) { if ($this->rewrite->concernsInstance($block)) {
$skipped[] = ['id' => $id, 'reason' => self::reason($block)]; $skipped[] = ['id' => $id, 'reason' => $this->reason($block)];
} }
continue; continue;
} }
@@ -55,11 +57,13 @@ final class TreeRewrite
return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped]; return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped];
} }
private static function reason(Block $block): string private function reason(Block $block): string
{ {
$ref = NodeSummary::globalRef($block); $ref = NodeSummary::globalRef($block);
if ($ref !== null) { if ($ref !== null) {
return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']); return $this->revealLibraryIds
? sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id'])
: 'globales Modul – im Divi-Builder ändern';
} }
return $block->isGlobal() return $block->isGlobal()
@@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageQuery; use LIT\MCP\Integrations\Divi\Library\UsageQuery;
use LIT\MCP\Integrations\Divi\Library\UsageReport; use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
@@ -94,13 +95,18 @@ final class PageRewriterTest extends TestCase
/** /**
* The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every * The rewriter over $posts: they are the candidates (ID order) and what get_post() finds; the caller has every
* capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id). * capability. $report answers reportFor() (default: all posts, nothing blocked), $libraryReports report($id);
* $loader defaults to one whose user may see library IDs.
* *
* @param list<WP_Post> $posts * @param list<WP_Post> $posts
* @param array<int, UsageReport> $libraryReports * @param array<int, UsageReport> $libraryReports
*/ */
private function rewriter(array $posts, ?UsageReport $report = null, array $libraryReports = []): PageRewriter private function rewriter(
{ array $posts,
?UsageReport $report = null,
array $libraryReports = [],
?DiviPageLoader $loader = null
): PageRewriter {
LibraryFixtures::given($posts); LibraryFixtures::given($posts);
$rows = array_map( $rows = array_map(
static fn (WP_Post $post): array => [ static fn (WP_Post $post): array => [
@@ -117,7 +123,7 @@ final class PageRewriterTest extends TestCase
static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0) static fn (int $id): UsageReport => $libraryReports[$id] ?? self::report(0)
); );
return new PageRewriter($usages, LibraryFixtures::loader(), $this->saver(), $this->log); return new PageRewriter($usages, $loader ?? LibraryFixtures::loader(), $this->saver(), $this->log);
} }
/** Records post ID and scope, puts the content on the post and reports revision 900 + n. */ /** Records post ID and scope, puts the content on the post and reports revision 900 + n. */
@@ -454,6 +460,38 @@ final class PageRewriterTest extends TestCase
self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content); self::assertStringContainsString('"modulePreset":["p-cta"]', (string) $post->post_content);
} }
/** Ruling P13: a user who may see library IDs is told which entry to edit. */
public function testGlobalInstanceReasonNamesTheEntryWhenIdsAreVisible(): void
{
$rewriter = $this->rewriter([self::page(101, self::layout(self::INSTANCE, self::BUTTON))]);
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
self::assertSame(
's0.r0.c0.g0: globales Modul – im Bibliothekseintrag 1059 ändern',
$preview['skipped'][0]['reason']
);
self::assertSame($preview['skipped'], $result['skipped']);
}
/** Ruling P13: without the library right the reason names no library ID (dry run and write). */
public function testGlobalInstanceReasonHidesTheEntryWithoutLibraryRights(): void
{
$loader = LibraryFixtures::loader(LibraryFixtures::access(['divi_library']));
$rewriter = $this->rewriter([self::page(101, self::layout(self::INSTANCE, self::BUTTON))], null, [], $loader);
self::assertFalse($loader->libraryIdsVisible());
$preview = $rewriter->preview(self::query(), self::assign(), null);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY);
$expected = [['post_id' => 101, 'reason' => 's0.r0.c0.g0: globales Modul – im Divi-Builder ändern']];
self::assertSame($expected, $preview['skipped']);
self::assertSame($expected, $result['skipped']);
self::assertSame([101], array_column($result['saved'], 'post_id'));
self::assertStringNotContainsString('1059', (string) json_encode([$preview, $result]));
}
/** R8: library entries are usages of their own, saved with DiviWriter's scope. */ /** R8: library entries are usages of their own, saved with DiviWriter's scope. */
public function testLibraryEntriesAreSavedWithTheirScope(): void public function testLibraryEntriesAreSavedWithTheirScope(): void
{ {
@@ -86,7 +86,7 @@ final class TreeRewriteTest extends TestCase
); );
$before = Serializer::document($document); $before = Serializer::document($document);
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); $result = (new TreeRewrite(self::touchButtons(), true))->apply(NodeIndex::build($document));
self::assertSame([], $result['changed']); self::assertSame([], $result['changed']);
self::assertSame( self::assertSame(
@@ -96,6 +96,21 @@ final class TreeRewriteTest extends TestCase
self::assertSame($before, Serializer::document($document), 'instances are never rewritten'); self::assertSame($before, Serializer::document($document), 'instances are never rewritten');
} }
/** Ruling P13, fail closed: without $revealLibraryIds the reason names no library ID. */
public function testGlobalInstanceReasonHidesTheLibraryIdByDefault(): void
{
$document = self::document(
'<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/button"} /-->'
);
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame(
[['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – im Divi-Builder ändern']],
$result['skipped']
);
}
public function testBlocksWithUnreadableAttributesAreSkippedToo(): void public function testBlocksWithUnreadableAttributesAreSkippedToo(): void
{ {
$document = self::document('<!-- wp:divi/button {"a":1,} /-->'); $document = self::document('<!-- wp:divi/button {"a":1,} /-->');