From f82964f6173540b94218c16be450ad849822e9ca Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 21:13:33 +0200 Subject: [PATCH] feat: structure and content fingerprints, lit/library-find-similar Fingerprint::structure()/content() (E11) and lit/library-find-similar (indirect, group Divi-Bibliothek): at most 200 posts per call (cursor), editable entries only (own posts without edit_others_posts), contents primed in chunks of 50, stops at 200 matches or 5000 compared nodes with a note; library entries and Theme Builder layouts are never scanned. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/BlockTree/Fingerprint.php | 85 ++++ src/Integrations/Divi/DiviIntegration.php | 3 +- .../Library/LibraryFindSimilarAbility.php | 373 ++++++++++++++++++ .../Divi/Library/LibraryIntegration.php | 14 +- .../Divi/BlockTree/FingerprintTest.php | 71 ++++ .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../Library/LibraryFindSimilarAbilityTest.php | 215 ++++++++++ 7 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 src/Integrations/Divi/BlockTree/Fingerprint.php create mode 100644 src/Integrations/Divi/Library/LibraryFindSimilarAbility.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php diff --git a/src/Integrations/Divi/BlockTree/Fingerprint.php b/src/Integrations/Divi/BlockTree/Fingerprint.php new file mode 100644 index 0000000..df30301 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Fingerprint.php @@ -0,0 +1,85 @@ +isOpaque()) { + return 'opaque:' . GlobalInstance::effectiveName($block); + } + + $presets = NodeSummary::presetIds($block); + sort($presets); + $children = array_map(self::shape(...), $block->children()); + + return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets) + . '[' . implode(';', $children) . ']'; + } + + private static function body(Block $block): string + { + if ($block->isOpaque()) { + return 'opaque:' . (string) $block->rawOpen(); + } + + $attrs = $block->attrs(); + foreach (self::IGNORED as $key) { + unset($attrs[$key]); + } + if (is_array($attrs['module']['meta'] ?? null)) { + unset($attrs['module']['meta']['adminLabel']); + } + $children = array_map(self::body(...), $block->children()); + + return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']'; + } + + /** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */ + private static function sorted(mixed $value): mixed + { + if ($value instanceof stdClass) { + $value = (array) $value; + } + if (! is_array($value)) { + return $value; + } + if (! array_is_list($value)) { + ksort($value); + } + + return array_map(self::sorted(...), $value); + } + + private static function hash(string $value): string + { + return substr(hash('sha256', $value), 0, 16); + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 21d6317..2eca391 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -168,7 +168,8 @@ final class DiviIntegration implements Integration new DiviSaver(), $this->log, $this->modules, - $this->diviWritable + $this->diviWritable, + $this->schema ); } diff --git a/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php new file mode 100644 index 0000000..d6cde32 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php @@ -0,0 +1,373 @@ + 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'], + 'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'], + 'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS], + 'cursor' => ['type' => 'string'], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'reference' => ['type' => 'object'], + 'matches' => ['type' => 'array', 'items' => ['type' => 'object']], + 'scanned' => ['type' => 'integer'], + 'next_cursor' => ['type' => ['string', 'null']], + 'note' => ['type' => 'string'], + ], + 'required' => ['reference', 'matches', 'scanned', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return $this->access->canList() ?? true; + } + + public function execute(array $input): mixed + { + $refusal = $this->access->canList(); + if ($refusal !== null) { + return $refusal; + } + + $reference = $this->reference($input); + if ($reference instanceof WP_Error) { + return $reference; + } + + $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $types = $this->postTypes($input['post_type'] ?? null); + if ($types instanceof WP_Error) { + return $types; + } + + $limit = is_int($input['limit'] ?? null) + ? max(1, min(self::MAX_POSTS, $input['limit'])) + : self::DEFAULT_POSTS; + $args = [ + 'post_type' => $types, + 'post_status' => self::LIVING, + 'posts_per_page' => $limit + 1, + 'offset' => $offset, + 'orderby' => 'ID', + 'order' => 'ASC', + 'fields' => 'ids', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + foreach ($types as $type) { + $own = PostGuard::ownPostsOnly($type); + if ($own !== null) { + $args['author'] = $own; + break; + } + } + $ids = array_values(array_map('intval', (array) get_posts($args))); + $scan = array_slice($ids, 0, $limit); + + $matches = []; + $nodes = 0; + $scanned = 0; + foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) { + $fresh = _get_non_cached_ids($chunk, 'posts'); + _prime_post_caches($chunk, false, false); + foreach ($chunk as $id) { + if (self::full($matches, $nodes)) { + break; + } + ++$scanned; + $post = current_user_can('edit_post', $id) ? get_post($id) : null; + if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::NOT_SCANNED, true)) { + $this->collect($post, $reference, $matches, $nodes); + } + } + foreach ($fresh as $id) { + wp_cache_delete((int) $id, 'posts'); + } + if (self::full($matches, $nodes)) { + break; + } + } + + $stopped = $scanned < count($scan); + $result = [ + 'reference' => $reference['info'], + 'matches' => $matches, + 'scanned' => $scanned, + 'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null, + ]; + if (self::full($matches, $nodes)) { + $result['note'] = sprintf( + 'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); ' + . 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type ' + . 'eingrenzen.', + $scanned, + self::MAX_MATCHES, + self::MAX_NODES + ); + } + + return $result; + } + + /** @param list> $matches */ + private static function full(array $matches, int $nodes): bool + { + return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES; + } + + /** + * @param array $input + * @return array{block: Block, info: array, post_id: ?int, id: ?string}|WP_Error + */ + private function reference(array $input): array|WP_Error + { + if (isset($input['library_id'])) { + $item = $this->access->item($input['library_id'], false); + if ($item === null) { + return PostGuard::notFound((int) PostGuard::id($input['library_id'])); + } + if ($item instanceof WP_Error) { + return $item; + } + + try { + $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content)); + } catch (MalformedMarkup) { + $roots = []; + } + if (count($roots) !== 1 || $roots[0]->isOpaque()) { + return Errors::invalidInput( + sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID), + ['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul'] + ); + } + + return self::describe($roots[0], null, null, (int) $item->ID); + } + + if (! isset($input['post_id'])) { + return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [ + 'library_id' => 'oder post_id und id', + ]); + } + + $page = $this->loader->load($input['post_id'], false); + if ($page instanceof WP_Error) { + return $page; + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + $node = $page->index->find($id); + if ($node === null) { + return DiviErrors::unknownNode($id, $page->post); + } + if ($node->isOpaque()) { + return Errors::invalidInput( + 'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.', + ['id' => 'lesbarer Knoten'] + ); + } + + return self::describe($node, (int) $page->post->ID, $id, null); + } + + /** @return array{block: Block, info: array, post_id: ?int, id: ?string} */ + private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array + { + $info = array_filter([ + 'type' => $block->name(), + 'structure' => Fingerprint::structure($block), + 'content' => Fingerprint::content($block), + 'post_id' => $postId, + 'id' => $id, + 'library_id' => $libraryId, + ], static fn (mixed $value): bool => $value !== null); + + return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id]; + } + + /** @return list|WP_Error */ + private function postTypes(mixed $postType): array|WP_Error + { + if ($postType !== null) { + $type = in_array($postType, self::NOT_SCANNED, true) + ? PostGuard::postType('', $this->schema, 'edit_posts') + : PostGuard::postType($postType, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : [(string) $postType]; + } + + $types = array_values(array_filter( + array_diff($this->schema->postTypeNames(), self::NOT_SCANNED), + fn (string $type): bool => $this->schema->diviEnabled($type) + )); + + return $types !== [] ? $types : Errors::notFound( + 'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.', + 'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.' + ); + } + + /** + * @param array{block: Block, info: array, post_id: ?int, id: ?string} $reference + * @param list> $matches + */ + private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void + { + $content = (string) $post->post_content; + if (! str_contains($content, '' . $this->variation . $copy . '', + ]), + new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Fremd', 'post_content' => $copy]), + ]; + $items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)); + LibraryFixtures::given(array_merge($posts, $items)); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9) + ); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [ + 'edit_posts' => 'edit_posts', + 'edit_others_posts' => 'edit_others_posts', + ]]); + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->alias(function (array $ids): void { + $this->primed[] = $ids; + }); + Functions\when('wp_cache_delete')->justReturn(true); + } + + /** @var list> */ + private array $primed = []; + + private static function ability(array $deniedRoles = []): LibraryFindSimilarAbility + { + $access = LibraryFixtures::access($deniedRoles); + + return new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema()); + } + + public function testFindsIdenticalAndStructurallySimilarNodesOfEditableEntries(): void + { + $query = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$query): array { + $query = $args; + + return [7, 8, 9]; + }); + + $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); + + self::assertSame(1055, $result['reference']['library_id']); + self::assertSame('divi/section', $result['reference']['type']); + $found = array_map(static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches']); + self::assertSame(['7 s1 identical', '8 s0 structure', '8 s1 identical'], $found, 'post 9 is not editable'); + self::assertSame(Rev::of((string) get_post(8)->post_content), $result['matches'][1]['tree_rev']); + self::assertSame(3, $result['scanned']); + self::assertNull($result['next_cursor']); + self::assertSame(['page'], $query['post_type']); + self::assertSame(101, $query['posts_per_page']); + self::assertSame('ids', $query['fields']); + } + + public function testANodeOfAPageIsTheReferenceAndIsNotItsOwnMatch(): void + { + Functions\when('get_posts')->justReturn([7, 8]); + + $result = self::ability()->execute(['post_id' => 7, 'id' => 's1', 'post_type' => 'page', 'limit' => 1]); + + self::assertSame(['s1'], [$result['reference']['id']]); + self::assertSame([], array_filter($result['matches'], static fn (array $m): bool => $m['post_id'] === 7)); + self::assertSame(1, $result['scanned']); + self::assertSame(Cursor::encode(1), $result['next_cursor']); + } + + public function testRefusals(): void + { + Functions\when('get_posts')->justReturn([]); + + self::assertToolError(self::ability(['divi_library'])->execute(['library_id' => 1055]), 'forbidden'); + self::assertToolError(self::ability()->execute([]), 'validation_failed'); + self::assertToolError(self::ability()->execute(['library_id' => 1054]), 'validation_failed', 'a layout entry has several roots'); + self::assertToolError(self::ability()->execute(['post_id' => 7, 'id' => 'g0']), 'validation_failed'); + self::assertToolError(self::ability()->execute(['library_id' => 1055, 'cursor' => '%%%']), 'validation_failed'); + self::assertTrue(self::ability()->annotations()['readonly']); + } + + /** No N+1: the contents come in chunks of CONTENT_CHUNK through one primed post cache each. */ + public function testLoadsTheContentsInChunks(): void + { + $ids = range(1000, 1000 + LibraryFindSimilarAbility::CONTENT_CHUNK + 4); + Functions\when('get_posts')->justReturn($ids); + + $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'limit' => 200]); + + self::assertSame(count($ids), $result['scanned']); + self::assertSame([array_slice($ids, 0, LibraryFindSimilarAbility::CONTENT_CHUNK), array_slice( + $ids, + LibraryFindSimilarAbility::CONTENT_CHUNK + )], $this->primed); + } + + /** Without edit_others_posts the query itself keeps to the user's own posts (no cursor oracle). */ + public function testWithoutEditOthersPostsOnlyOwnPostsAreQueried(): void + { + $query = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$query): array { + $query = $args; + + return []; + }); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(42); + + self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); + + self::assertSame(42, $query['author']); + } + + /** Library entries and Theme Builder layouts are never scanned (they have their own gates). */ + public function testLibraryAndThemeBuilderTypesAreNotSearchable(): void + { + Functions\expect('get_posts')->never(); + + foreach (['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template'] as $type) { + self::assertToolError( + self::ability()->execute(['library_id' => 1055, 'post_type' => $type]), + 'not_found' + ); + } + } + + /** With the library group hidden the library entry is answered like a missing ID (P9/P12). */ + public function testAHiddenLibraryAnswersLikeAMissingEntry(): void + { + Functions\when('get_posts')->justReturn([]); + $access = new \LIT\MCP\Integrations\Divi\Library\LibraryAccess( + LibraryFixtures::store(), + LibraryFixtures::roles(), + static fn (): bool => true, + static fn (): bool => false + ); + $ability = new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema()); + + $hidden = $ability->execute(['library_id' => 1055, 'post_type' => 'page']); + $missing = $ability->execute(['library_id' => 4711, 'post_type' => 'page']); + + self::assertToolError($hidden, 'not_found'); + self::assertSame( + str_replace('1055', 'X', $hidden->get_error_message()), + str_replace('4711', 'X', $missing->get_error_message()) + ); + } + + /** At most MAX_MATCHES matches: the scan stops after that post, says so and resumes behind it. */ + public function testStopsAtMaxMatchesWithANoteAndACursor(): void + { + $section = Serializer::block(LibraryContent::roots(LibraryContent::load( + new Parser(), + DiviFixtures::markup('stage3/library--library-abschnitt.html') + ))[0]); + $many = static fn (int $id, int $count): WP_Post => new WP_Post([ + 'ID' => $id, + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Viele ' . $id, + 'post_content' => '' . str_repeat($section, $count) + . '', + ]); + $items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS)); + LibraryFixtures::given(array_merge([$many(20, 150), $many(21, 120), $many(22, 5)], $items)); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]); + Functions\when('get_posts')->justReturn([20, 21, 22]); + + $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); + + self::assertCount(LibraryFindSimilarAbility::MAX_MATCHES, $result['matches']); + self::assertSame(2, $result['scanned']); + self::assertSame(Cursor::encode(2), $result['next_cursor']); + self::assertStringContainsString('200', $result['note']); + + Functions\when('get_posts')->justReturn([22]); + $rest = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'cursor' => Cursor::encode(2)]); + self::assertCount(5, $rest['matches']); + self::assertArrayNotHasKey('note', $rest); + } +}