diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 0d45ee7..f6738e4 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -66,6 +66,17 @@ class DiviPageLoader return new DiviPage($post, $document, NodeIndex::build($document)); } + /** The entry loaded for writing and checked by writable() – the refusal of either step. */ + public function loadWritable(mixed $postId): DiviPage|WP_Error + { + $page = $this->load($postId, true); + if ($page instanceof WP_Error) { + return $page; + } + + return $this->writable($page) ?? $page; + } + /** * Additional checks for writing: the type must use the Divi builder, the content must be a * Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index c886ecd..02a6c55 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -11,8 +11,6 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\BlockTree\Block; -use LIT\MCP\Integrations\Divi\BlockTree\Serializer; -use LIT\MCP\Integrations\Divi\Compose\StoredForms; use LIT\MCP\Integrations\Divi\Compose\TextReplacer; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -39,16 +37,15 @@ final class DiviReplaceTextAbility implements Ability private const DRY_RUN_LIMIT = 10; - private const SAMPLES = 5; - - public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten'; + private DiviTextSearch $search; public function __construct( private ContentSchema $schema, private DiviPageLoader $loader, private DiviWriter $writer, - private ?\wpdb $db = null + ?\wpdb $db = null ) { + $this->search = new DiviTextSearch($db); } public function name(): string @@ -172,7 +169,8 @@ final class DiviReplaceTextAbility implements Ability } $limit = Cursor::limit($input['limit'] ?? null, self::DRY_RUN_LIMIT); - $candidates = $this->candidates($postType, (string) $input['search'], $caseSensitive, $limit + 1, $offset); + $search = (string) $input['search']; + $candidates = $this->search->candidates($postType, $search, $caseSensitive, $limit + 1, $offset); $page = Cursor::page($candidates, $limit, $offset); $matches = []; $skipped = []; @@ -184,15 +182,14 @@ final class DiviReplaceTextAbility implements Ability continue; } - $divi = $this->loader->load($postId, true); - $problem = $divi instanceof WP_Error ? $divi : $this->loader->writable($divi); - if ($problem instanceof WP_Error) { - $skipped[] = ['post_id' => $postId, 'reason' => Errors::clientMessage($problem)]; + $divi = $this->loader->loadWritable($postId); + if ($divi instanceof WP_Error) { + $skipped[] = ['post_id' => $postId, 'reason' => Errors::clientMessage($divi)]; continue; } try { - $found = $this->replace($divi, $replacer()); + $found = $this->hits($divi, $replacer()); } catch (InvalidArgumentException $e) { $skipped[] = ['post_id' => $postId, 'reason' => $e->getMessage()]; continue; @@ -319,11 +316,11 @@ final class DiviReplaceTextAbility implements Ability try { return $this->saveReplaced($replacer, $input); } catch (WriteFailed $e) { - self::log($postId, $e); + Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e); return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($e->toError())]; } catch (Throwable $e) { - self::log($postId, $e); + Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e); return [ 'post_id' => $postId, @@ -350,7 +347,7 @@ final class DiviReplaceTextAbility implements Ability $post = $page->post; $base = ['post_id' => $postId, 'type' => (string) $post->post_type, 'title' => (string) $post->post_title]; try { - $found = $this->replace($page, $replacer); + $found = $this->hits($page, $replacer); } catch (InvalidArgumentException $e) { return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.']; } @@ -379,102 +376,15 @@ final class DiviReplaceTextAbility implements Ability } /** - * Every hit of $page, computed without changing it. The replacement is cleaned per place by - * the entry's ValueCleaner (field kind; markers rejected); URL and code fields are skipped. + * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's + * own composition (ruling S22). * * @return array{count: int, samples: list>, * blocks: list}>, skipped_nodes: list} * @throws InvalidArgumentException When a place would be changed unsafely – the page fails. */ - private function replace(DiviPage $page, TextReplacer $replacer): array + private function hits(DiviPage $page, TextReplacer $replacer): array { - $cleaner = $this->writer->composition($page)->cleaner(); - $count = 0; - $samples = []; - $blocks = []; - $skipped = []; - foreach ($page->index->entries() as $entry) { - $block = $entry['block']; - if ($block->isDivi() && $block->isOpaque()) { - if ($replacer->mentions(Serializer::block($block))) { - $skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)]; - } - continue; - } - if (! $block->isDivi()) { - continue; - } - - try { - $result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name())); - } catch (InvalidArgumentException $e) { - throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e); - } - - if ($result['count'] > 0) { - $count += $result['count']; - $blocks[] = [$block, $result['attrs']]; - foreach ($result['samples'] as $sample) { - if (count($samples) < self::SAMPLES) { - $samples[] = ['id' => (string) $entry['id']] + $sample; - } - } - } - } - - return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped]; - } - - /** - * IDs of Divi entries whose content contains the search text in one of its stored forms - * (StoredForms::of()). The exact match happens in the tree; this is only a prefilter. - * - * @return list - */ - private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array - { - $db = $this->db(); - $forms = StoredForms::of($search, $caseSensitive); - $likes = array_map( - static fn (string $form): string => '%' . $db->esc_like($form) . '%', - $forms - ); - - $sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s" - . " AND post_status NOT IN ('trash', 'auto-draft', 'inherit')" - . ' AND post_content LIKE %s AND (' . implode(' OR ', array_fill(0, count($likes), 'post_content LIKE %s')) - . ')'; - $args = array_merge([$postType, '%' . $db->esc_like('\n

Gutenberg

\n"); + self::assertToolError($loader->loadWritable(7), 'unsupported'); + } }