From e7bbe1e9d78e37745854c1693c2dcf11ae590964 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 23:09:13 +0200 Subject: [PATCH] fix: find-similar marks replaceable matches and names excluded types (review M-3, M-7, M-8) Every match carries replaceable: true only when it is identical and in a page or post, the scope lit/library-make-global replaces; the description and the guide say so. An excluded post type is answered like any unavailable one, by its name (PostGuard::unavailableType()), and the excluded list takes the Theme Builder types from DiviThemeBuilder::postTypes(). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/divi-library.md | 8 ++-- .../Library/LibraryFindSimilarAbility.php | 40 +++++++++------- src/Integrations/WordPress/PostGuard.php | 14 ++++-- .../Library/LibraryFindSimilarAbilityTest.php | 46 +++++++++++++++++-- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index fea451e..87a09c2 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -41,7 +41,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv Durchsucht werden Einträge der Divi-Inhaltstypen (optional nur `post_type`), nie Bibliothekseinträge oder Theme-Builder-Layouts; nur Einträge, die das Konto bearbeiten darf. Höchstens 200 Einträge je Aufruf (`limit`), weiter mit `next_cursor`; bei sehr vielen Treffern endet ein Aufruf früher und sagt das in `note`. - Jeder Treffer nennt `tree_rev`. + Jeder Treffer nennt `tree_rev` und `replaceable` (`true`: inhaltsgleich und in einer Seite oder einem Beitrag – + nur solche Treffer ersetzt `lit/library-make-global`). - `lit/library-make-global`: einen Knoten einer Seite oder eines Beitrags in ein globales Modul umwandeln (`post_id`, `tree_rev`, `id`, `title`, optional `categories`/`tags`): neuer globaler Eintrag mit genau diesem Inhalt, der Knoten wird durch die Instanz ersetzt – alles oder nichts. Weitere, inhaltsgleiche Vorkommen @@ -139,6 +140,7 @@ Fügt eine Divi-Änderung einen Abschnitt ein, der wie ein Abschnitt der Bibliot Treffer mit `match: "structure"` unterscheiden sich im Inhalt – vorher klären, welche Fassung gilt, und die übrigen angleichen (`lit-divi-update`) oder weglassen. 3. Nach Zustimmung `lit/library-make-global` mit dem gewählten Abschnitt. -4. Die inhaltsgleichen Vorkommen mit `lit/library-make-global` (`library_id`, `targets` mit der `tree_rev` aus - `lit/library-find-similar`) ersetzen: Probelauf zeigen, dann `confirm: true`. +4. Die Treffer mit `replaceable: true` mit `lit/library-make-global` (`library_id`, `targets` mit der `tree_rev` + aus `lit/library-find-similar`) ersetzen: Probelauf zeigen, dann `confirm: true`. Inhaltsgleiche Treffer in + anderen Inhaltstypen (`replaceable: false`) im Divi-Builder durch das globale Modul ersetzen. 5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen. diff --git a/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php index d6cde32..a162d31 100644 --- a/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php +++ b/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php @@ -18,6 +18,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Pages\DiviErrors; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; +use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -48,15 +49,6 @@ final class LibraryFindSimilarAbility implements Ability /** Post contents read with one primed cache (bounded memory). */ public const CONTENT_CHUNK = 50; - /** Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them. */ - private const NOT_SCANNED = [ - DiviLibraryStore::POST_TYPE, - 'et_theme_builder', - 'et_template', - 'et_header_layout', - 'et_body_layout', - 'et_footer_layout', - ]; private const LIVING = ['publish', 'draft', 'pending', 'private', 'future']; @@ -84,8 +76,8 @@ final class LibraryFindSimilarAbility implements Ability . 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher ' . 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit ' . 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf ' - . '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen ersetzt lit/library-make-global ' - . '(library_id, targets).'; + . '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) ' + . 'ersetzt lit/library-make-global (library_id, targets).'; } public function inputSchema(): array @@ -192,7 +184,7 @@ final class LibraryFindSimilarAbility implements Ability } ++$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)) { + if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) { $this->collect($post, $reference, $matches, $nodes); } } @@ -302,19 +294,31 @@ final class LibraryFindSimilarAbility implements Ability return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id]; } + /** + * Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them + * (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()). + * + * @return list + */ + private static function notScanned(): array + { + return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes()); + } + /** @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') + // Review M-7: an excluded type answers like any unavailable one, by its name. + $type = in_array($postType, self::notScanned(), true) + ? PostGuard::unavailableType((string) $postType) : 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), + array_diff($this->schema->postTypeNames(), self::notScanned()), fn (string $type): bool => $this->schema->diviEnabled($type) )); @@ -355,6 +359,7 @@ final class LibraryFindSimilarAbility implements Ability continue; } + $identical = Fingerprint::content($block) === $reference['info']['content']; $matches[] = [ 'post_id' => (int) $post->ID, 'title' => (string) $post->post_title, @@ -363,7 +368,10 @@ final class LibraryFindSimilarAbility implements Ability 'id' => $entry['id'], 'label' => NodeSummary::label($block), 'tree_rev' => Rev::of($content), - 'match' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure', + 'match' => $identical ? 'identical' : 'structure', + // Review M-8: lit/library-make-global replaces identical occurrences in pages and posts only. + 'replaceable' => $identical + && in_array((string) $post->post_type, LibraryMakeGlobalAbility::POST_TYPES, true), ]; if (count($matches) >= self::MAX_MATCHES) { return; diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index 1cc8be4..a0bd418 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -136,15 +136,21 @@ final class PostGuard * so a plain `object` return type is accurate and still lets callers branch with * `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above. */ + /** The answer for a post type that is not available to the tools – the same for unknown and excluded ones. */ + public static function unavailableType(string $name): WP_Error + { + return Errors::notFound( + sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name), + 'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.' + ); + } + public static function postType(mixed $postType, ContentSchema $schema, string $cap): object { $name = is_string($postType) ? $postType : ''; $object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null; if (! is_object($object)) { - return Errors::notFound( - sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name), - 'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.' - ); + return self::unavailableType($name); } $capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap; diff --git a/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php index 23fe3b4..695b45c 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php @@ -150,14 +150,50 @@ final class LibraryFindSimilarAbilityTest extends TestCase { 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' - ); + foreach ( + ['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template', 'et_theme_builder'] + as $type + ) { + $refused = self::ability()->execute(['library_id' => 1055, 'post_type' => $type]); + self::assertToolError($refused, 'not_found'); + // Review M-7: named like any unavailable type, never as "". + self::assertSame(sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $type), self::clientError($refused)['message']); } } + /** + * Review M-8: lit/library-make-global replaces only pages and posts – every match says whether it can be + * replaced there (identical and of such a type). + */ + public function testMatchesSayWhetherMakeGlobalCanReplaceThem(): void + { + $project = get_post(8); + $project = new WP_Post(['ID' => 10, 'post_type' => 'project', 'post_status' => 'publish', 'post_title' => 'Projekt', + 'post_content' => $project->post_content]); + LibraryFixtures::given(array_merge([get_post(8), $project], array_map( + static fn (int $id): WP_Post => LibraryFixtures::item($id), + array_keys(LibraryFixtures::ITEMS) + ))); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [ + 'edit_posts' => 'edit_posts', + 'edit_others_posts' => 'edit_others_posts', + ]]); + // The query is mocked: it stands for a scan over several Divi-enabled types. + Functions\when('get_posts')->justReturn([8, 10]); + + $result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']); + + $found = array_map( + static fn (array $m): string => sprintf('%d %s %s %s', $m['post_id'], $m['id'], $m['match'], json_encode($m['replaceable'])), + $result['matches'] + ); + self::assertSame( + ['8 s0 structure false', '8 s1 identical true', '10 s0 structure false', '10 s1 identical false'], + $found + ); + self::assertStringContainsString('replaceable', self::ability()->description()); + } + /** With the library group hidden the library entry is answered like a missing ID (P9/P12). */ public function testAHiddenLibraryAnswersLikeAMissingEntry(): void {