'object', 'properties' => array_merge([ 'search' => ['type' => 'string', 'minLength' => 2, 'description' => 'Gesuchter Text (sichtbar).'], 'replace' => ['type' => 'string', 'description' => 'Ersatz; leer entfernt den Text.'], 'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, Standard "page".'], 'case_sensitive' => ['type' => 'boolean', 'description' => 'Standard true.'], 'dry_run' => ['type' => 'boolean', 'description' => 'Standard true: nur Treffer anzeigen.'], 'targets' => [ 'type' => 'array', 'minItems' => 1, 'maxItems' => self::MAX_TARGETS, 'items' => [ 'type' => 'object', 'properties' => [ 'post_id' => ['type' => 'integer', 'minimum' => 1], 'tree_rev' => ['type' => 'string'], ], 'required' => ['post_id', 'tree_rev'], 'additionalProperties' => false, ], 'description' => 'Nur mit dry_run: false – post_id und tree_rev je Seite aus dem Probelauf.', ], 'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'], ], Cursor::inputProperties()), 'required' => ['search', 'replace'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema([ 'dry_run' => ['type' => 'boolean'], 'matches' => ['type' => 'array', 'items' => ['type' => 'object']], 'skipped' => ['type' => 'array', 'items' => ['type' => 'object']], 'skipped_nodes' => ['type' => 'array', 'items' => ['type' => 'object']], 'next_cursor' => ['type' => ['string', 'null']], 'results' => ['type' => 'array', 'items' => ['type' => 'object']], ], ['dry_run']); } public function annotations(): array { return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; } public function isDirect(): bool { return false; } public function permission(array $input): bool|WP_Error { $type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts'); return $type instanceof WP_Error ? $type : true; } public function execute(array $input): mixed { $postType = is_string($input['post_type'] ?? null) ? $input['post_type'] : 'page'; $type = PostGuard::postType($postType, $this->schema, 'edit_posts'); if ($type instanceof WP_Error) { return $type; } $search = is_string($input['search'] ?? null) ? $input['search'] : ''; $replace = is_string($input['replace'] ?? null) ? $input['replace'] : null; $problems = TextReplacer::problems($search, $replace ?? ''); if ($replace === null) { $problems['replace'] = 'Ersetzung (Text, ggf. leer) fehlt.'; } if ($problems !== []) { return Errors::invalidInput('Suchtext oder Ersetzung ungültig; nichts wurde geändert.', $problems); } $caseSensitive = ($input['case_sensitive'] ?? true) !== false; $replacer = static fn (): TextReplacer => new TextReplacer($search, (string) $replace, $caseSensitive); return ($input['dry_run'] ?? true) !== false ? $this->dryRun($postType, $replacer, $caseSensitive, $input) : $this->run($replacer, $input); } /** * @param callable(): TextReplacer $replacer * @param array $input * @return array|WP_Error */ private function dryRun(string $postType, callable $replacer, bool $caseSensitive, array $input): array|WP_Error { $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null); if ($offset === null) { return Cursor::invalid(); } $limit = Cursor::limit($input['limit'] ?? null, self::DRY_RUN_LIMIT); $candidates = $this->candidates($postType, (string) $input['search'], $caseSensitive, $limit + 1, $offset); $page = Cursor::page($candidates, $limit, $offset); $matches = []; $skipped = []; $skippedNodes = []; $total = 0; foreach ($page['items'] as $postId) { $postId = (int) $postId; if (! current_user_can('edit_post', $postId)) { 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)]; continue; } try { $found = $this->replace($divi, $replacer()); } catch (InvalidArgumentException $e) { $skipped[] = ['post_id' => $postId, 'reason' => $e->getMessage()]; continue; } foreach ($found['skipped_nodes'] as $node) { $skippedNodes[] = ['post_id' => $postId] + $node; } if ($found['count'] > 0 || $found['skipped_nodes'] !== []) { $total += $found['count']; $matches[] = [ 'post_id' => $postId, 'title' => (string) $divi->post->post_title, 'status' => (string) $divi->post->post_status, 'tree_rev' => $divi->treeRev(), 'count' => $found['count'], 'samples' => $found['samples'], 'skipped_nodes' => count($found['skipped_nodes']), ]; } } return ToolResult::ok(sprintf( 'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s%s', $total, count($matches), $skippedNodes === [] ? '' : sprintf( ' %d Knoten (globale Module) werden nicht geändert, siehe skipped_nodes.', count($skippedNodes) ), $page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.' )) ->with('dry_run', true) ->with('matches', $matches) ->with('skipped', $skipped) ->with('skipped_nodes', $skippedNodes) ->with('next_cursor', $page['next_cursor']) ->toArray(); } /** * @param callable(): TextReplacer $replacer * @param array $input * @return array|WP_Error */ private function run(callable $replacer, array $input): array|WP_Error { $targets = is_array($input['targets'] ?? null) ? array_values($input['targets']) : []; $fields = []; foreach ($targets as $i => $target) { $valid = is_array($target) && PostGuard::id($target['post_id'] ?? null) !== null && is_string($target['tree_rev'] ?? null); if (! $valid) { $fields[sprintf('targets[%d]', $i)] = 'post_id und tree_rev aus dem Probelauf erwartet'; } } if ($targets === [] || count($targets) > self::MAX_TARGETS) { $fields['targets'] = sprintf( '1 bis %d Seiten mit post_id und tree_rev aus dem Probelauf (dry_run: true)', self::MAX_TARGETS ); } if ($fields !== []) { return Errors::invalidInput('targets fehlt oder ist ungültig; nichts wurde geändert.', $fields); } if (($input['confirm'] ?? false) !== true) { return Errors::confirmRequired(sprintf( '"%s" würde auf %d Seiten durch "%s" ersetzt.', (string) $input['search'], count($targets), (string) $input['replace'] )); } $results = []; foreach ($targets as $target) { $results[] = $this->replaceOn($replacer(), $target); } $replaced = array_filter($results, static fn (array $entry): bool => $entry['ok'] && $entry['count'] > 0); $tool = ToolResult::ok(sprintf( 'Text auf %d von %d Seiten ersetzt (%d Stellen).', count($replaced), count($results), array_sum(array_column($replaced, 'count')) )); $skippedNodes = []; foreach ($results as $entry) { foreach ($entry['skipped_nodes'] ?? [] as $node) { $skippedNodes[] = ['post_id' => $entry['post_id']] + $node; $tool->warning(sprintf('Eintrag %d, Knoten %s: %s', $entry['post_id'], $node['id'], $node['reason'])); } if (! $entry['ok']) { $tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $entry['error'])); continue; } foreach ($entry['warnings'] ?? [] as $warning) { $tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $warning)); } if ($entry['count'] > 0) { $tool->changed((string) $entry['post_id'], $entry['type'], 'replaced', $entry['title']); } } return $tool->with('dry_run', false) ->with('results', $results) ->with('skipped_nodes', $skippedNodes) ->toArray(); } /** * One page, all or nothing. Never throws (ruling S10): a WriteFailed keeps its client text, * every other Throwable is logged and reported neutrally – it may come after the save. * * @param array{post_id: int|string, tree_rev: string} $target * @return array */ private function replaceOn(TextReplacer $replacer, array $target): array { $postId = (int) $target['post_id']; $input = ['post_id' => $postId, 'tree_rev' => $target['tree_rev']]; try { return $this->saveReplaced($replacer, $input); } catch (WriteFailed $e) { self::log($postId, $e); return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($e->toError())]; } catch (Throwable $e) { self::log($postId, $e); return [ 'post_id' => $postId, 'ok' => false, 'error' => 'Interner Fehler bei dieser Seite (Details im PHP-Error-Log der Website). Vor einem ' . 'neuen Versuch den Stand mit lit/divi-outline (direkt: lit-divi-outline) prüfen.', ]; } } /** * @param array{post_id: int, tree_rev: string} $input * @return array * @throws WriteFailed */ private function saveReplaced(TextReplacer $replacer, array $input): array { $postId = $input['post_id']; $page = $this->writer->page($input); if ($page instanceof WP_Error) { return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($page)]; } $post = $page->post; $base = ['post_id' => $postId, 'type' => (string) $post->post_type, 'title' => (string) $post->post_title]; try { $found = $this->replace($page, $replacer); } catch (InvalidArgumentException $e) { return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.']; } $base['skipped_nodes'] = $found['skipped_nodes']; if ($found['count'] === 0) { return $base + ['ok' => true, 'count' => 0, 'tree_rev' => $page->treeRev()]; } $change = new DiviChange(sprintf('Text ersetzt (%d Stellen)', $found['count'])); foreach ($found['blocks'] as [$block, $attrs]) { $block->setAttrs($attrs); $change->changed($block, 'updated'); } $saved = $this->writer->save($this->name(), $page, $change, $input); return $base + [ 'ok' => true, 'count' => $found['count'], 'tree_rev' => $saved['tree_rev'] ?? null, 'revision_id' => $saved['revision_id'] ?? null, 'log_id' => $saved['log_id'] ?? null, 'warnings' => is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [], ]; } /** * 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. * * @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 { $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 * (TextReplacer::forms()). 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 = TextReplacer::forms($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('