From 4f708860066bce508ce6c72383e24091bf87020e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 21:16:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20write=20results=20warn=20about=20sectio?= =?UTF-8?q?ns=20built=20like=20a=20library=20section=20(spec=20=C2=A75.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimilarSections compares inserted sections (not global instances) with at most 200 editable library section entries by structure fingerprint, read lazily once per request, only while library IDs may be named (P13). lit/library-insert is not asked. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/DiviIntegration.php | 4 +- .../Divi/Library/SimilarSections.php | 95 ++++++++++++++++ src/Integrations/Divi/Pages/DiviWriter.php | 13 ++- tests/Support/DiviWriterFixture.php | 7 +- .../Divi/Library/SimilarSectionsTest.php | 101 ++++++++++++++++++ .../Divi/Pages/DiviWriterTest.php | 46 ++++++++ 6 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 src/Integrations/Divi/Library/SimilarSections.php create mode 100644 tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 2eca391..75a8134 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Library\LibraryIntegration; +use LIT\MCP\Integrations\Divi\Library\SimilarSections; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; @@ -194,7 +195,8 @@ final class DiviIntegration implements Integration $this->design, new DiviComposition($this->modules, $this->design), new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())), - new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)) + new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)), + new SimilarSections($this->access) ); } diff --git a/src/Integrations/Divi/Library/SimilarSections.php b/src/Integrations/Divi/Library/SimilarSections.php new file mode 100644 index 0000000..2fe2feb --- /dev/null +++ b/src/Integrations/Divi/Library/SimilarSections.php @@ -0,0 +1,95 @@ +|null Structure fingerprint => entry. */ + private ?array $index = null; + + public function __construct(private LibraryAccess $access, private Parser $parser = new Parser()) + { + } + + /** @return list */ + public function warnings(DiviChange $change): array + { + if (! $this->access->revealsIds()) { + return []; + } + + $warnings = []; + foreach ($change->entries() as $entry) { + $block = $entry['block']; + if ( + $entry['action'] !== 'inserted' + || ! $block instanceof Block + || $block->name() !== 'divi/section' + || $block->isOpaque() + || $block->isGlobal() + ) { + continue; + } + $match = $this->index()[Fingerprint::structure($block)] ?? null; + if ($match !== null) { + $warnings[] = sprintf( + 'Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek: „%s“ (%d) – lit-library-insert ' + . 'erwägen; weitere Vorkommen mit lit/library-find-similar.', + $match['title'], + $match['id'] + ); + } + } + + return array_values(array_unique($warnings)); + } + + /** @return array */ + private function index(): array + { + if ($this->index !== null) { + return $this->index; + } + + $this->index = []; + foreach ($this->access->store()->find(['layout_type' => 'section'], self::MAX_ITEMS - 1, 0) as $item) { + if (! current_user_can('edit_post', (int) $item->ID)) { + continue; + } + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + continue; + } + if (count($roots) === 1 && $roots[0]->name() === 'divi/section') { + $this->index[Fingerprint::structure($roots[0])] ??= [ + 'id' => (int) $item->ID, + 'title' => (string) $item->post_title, + ]; + } + } + + return $this->index; + } +} diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 470678c..c3ca304 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\Library\SimilarSections; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use WP_Error; @@ -27,13 +28,17 @@ defined('ABSPATH') || exit; * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point * of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme - * Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9). + * Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9). SimilarSections adds + * the "similar section" warning (spec §5.5, E11) – not for lit/library-insert, whose sections come from the library. */ final class DiviWriter { public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit ' . 'lit-divi-outline lesen.'; + /** Inserts library entries: no advice to use the library (SimilarSections). */ + private const FROM_LIBRARY = 'lit/library-insert'; + public function __construct( private DiviPageLoader $loader, private DiviSaver $saver, @@ -41,7 +46,8 @@ final class DiviWriter private DesignSystemReader $design, private DiviComposition $composition, private ?GlobalEdits $globals = null, - private ?ThemeBuilderEdits $themeBuilder = null + private ?ThemeBuilderEdits $themeBuilder = null, + private ?SimilarSections $similar = null ) { } @@ -106,7 +112,8 @@ final class DiviWriter $index = NodeIndex::build($page->document); $quality = $this->composition->warnings()->collect($index, $change->touched()); - $warnings = array_merge($change->warnings(), $quality); + $similar = $ability === self::FROM_LIBRARY ? [] : ($this->similar?->warnings($change) ?? []); + $warnings = array_merge($change->warnings(), $quality, $similar); try { $saved = $this->saver->save($post, $content, $scope); diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php index a9e1a87..8a54352 100644 --- a/tests/Support/DiviWriterFixture.php +++ b/tests/Support/DiviWriterFixture.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; +use LIT\MCP\Integrations\Divi\Library\SimilarSections; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; @@ -102,7 +103,8 @@ final class DiviWriterFixture bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null, - ?ThemeBuilderEdits $themeBuilder = null + ?ThemeBuilderEdits $themeBuilder = null, + ?SimilarSections $similar = null ): DiviWriter { $design = self::designReader(); @@ -113,7 +115,8 @@ final class DiviWriterFixture $design, self::composition($design, $unfiltered), $globals, - $themeBuilder + $themeBuilder, + $similar ); } } diff --git a/tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php b/tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php new file mode 100644 index 0000000..ee8c9ca --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php @@ -0,0 +1,101 @@ +copy(); + $blurb = $root->children()[0]->children()[0]->children()[0]; + $attrs = $blurb->attrs(); + $attrs['title']['innerContent']['desktop']['value'] = 'Neuer Titel'; + $blurb->setAttrs($attrs); + + return $root; + } + + /** @param list $deniedRoles */ + private static function similar(array $deniedRoles = []): SimilarSections + { + LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058)]); + $store = LibraryFixtures::store(); + $store->allows('find')->andReturn([LibraryFixtures::item(1055), LibraryFixtures::item(1058)]); + + return new SimilarSections(LibraryFixtures::access($deniedRoles, true, $store)); + } + + public function testWarnsForAnInsertedSectionBuiltLikeALibrarySection(): void + { + $change = (new DiviChange('1 Divi-Knoten eingefügt'))->record(self::sectionLike1055(), 'inserted'); + + $warnings = self::similar()->warnings($change); + + self::assertCount(1, $warnings); + self::assertStringContainsString('„[Fixture] 1055“ (1055)', $warnings[0]); + self::assertStringContainsString('lit-library-insert', $warnings[0]); + self::assertStringContainsString('lit/library-find-similar', $warnings[0]); + } + + public function testOnlyInsertedSectionsAndOnlyWithLibraryAccess(): void + { + $moved = (new DiviChange('verschoben'))->record(self::sectionLike1055(), 'moved'); + $inserted = (new DiviChange('eingefügt'))->record(self::sectionLike1055(), 'inserted'); + + self::assertSame([], self::similar()->warnings($moved)); + self::assertSame([], self::similar(['divi_library'])->warnings($inserted)); + } + + /** The library is read once per request, at most MAX_ITEMS section entries, only those the user may edit. */ + public function testReadsTheLibraryOnceBoundedAndOnlyEditableEntries(): void + { + LibraryFixtures::given([LibraryFixtures::item(1055)]); + \Brain\Monkey\Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1055) + ); + $store = LibraryFixtures::store(); + $store->expects('find')->once()->with(['layout_type' => 'section'], SimilarSections::MAX_ITEMS - 1, 0) + ->andReturn([LibraryFixtures::item(1055)]); + $similar = new SimilarSections(LibraryFixtures::access([], true, $store)); + $change = (new DiviChange('eingefügt'))->record(self::sectionLike1055(), 'inserted'); + + self::assertSame([], $similar->warnings($change), 'entry 1055 is not editable'); + self::assertSame([], $similar->warnings($change)); + } + + /** No section inserted: the library is not read at all (cheap on every write). */ + public function testWithoutAnInsertedSectionTheLibraryIsNotRead(): void + { + LibraryFixtures::given([]); + $store = LibraryFixtures::store(); + $store->expects('find')->never(); + $similar = new SimilarSections(LibraryFixtures::access([], true, $store)); + $row = self::sectionLike1055()->children()[0]; + + self::assertSame([], $similar->warnings((new DiviChange('eingefügt'))->record($row, 'inserted'))); + self::assertSame([], $similar->warnings((new DiviChange('geändert'))->record(self::sectionLike1055(), 'updated'))); + } + + /** A global instance is already linked to the library – no warning, even for a global section entry. */ + public function testGlobalInstancesDoNotWarn(): void + { + $instance = \LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance::of(self::sectionLike1055(), 1055); + $change = (new DiviChange('eingefügt'))->record($instance, 'inserted'); + + self::assertSame([], self::similar()->warnings($change)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index 772cf8a..7b2c997 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -10,8 +10,10 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Library\SimilarSections; use LIT\MCP\Integrations\Divi\Pages\DiviChange; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; @@ -245,4 +247,48 @@ final class DiviWriterTest extends TestCase $log->assertRecorded(); $log->assertPartials(); } + + /** Spec §5.5: the writer adds the similar-section warnings to every write result. */ + public function testWriteResultsCarryTheSimilarSectionWarning(): void + { + $content = DiviFixtures::markup('synthetic-basic.html'); + DiviFixtures::givenPost($content); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $similar = Mockery::mock(SimilarSections::class); + $similar->allows('warnings')->andReturn(['Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.']); + $saved = []; + $failures = []; + $writer = DiviWriterFixture::writer($saved, $failures, false, null, null, null, $similar); + + $result = (new DiviInsertAbility($writer))->execute([ + 'post_id' => 7, + 'tree_rev' => \LIT\MCP\Abilities\Rev::of($content), + 'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => 'Neu']]]], + ]); + + self::assertContains('Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.', $result['warnings']); + } + + /** What lit/library-insert inserts comes from the library – no advice to use the library. */ + public function testLibraryInsertIsNotAskedForTheSimilarSectionWarning(): void + { + $content = DiviFixtures::markup('synthetic-basic.html'); + DiviFixtures::givenPost($content); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + Functions\when('do_action')->justReturn(null); + $similar = Mockery::mock(SimilarSections::class); + $similar->expects('warnings')->never(); + $saved = []; + $failures = []; + $writer = DiviWriterFixture::writer($saved, $failures, false, null, null, null, $similar); + $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; + $page = $writer->page($input); + $section = $page->index->find('s1')->copy(); + $page->document->children()[0]->removeChild($page->index->find('s1')); + $change = (new DiviChange('eingefügt'))->record($section, 'inserted'); + + $result = $writer->save('lit/library-insert', $page, $change, $input); + + self::assertSame(811, $result['revision_id']); + } }