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']); + } }