feat: lit/divi-replace-text with dry run and per-page tree_rev targets

TextReplacer replaces only in plain-text segments (S8 split), outside
script/style bodies, inserts the replacement cleaned per field kind and
refuses any result whose tags, entities or references differ from the
original plus the inserted text. Unchanged strings are never cleaned.
Per page all or nothing with the tree_rev lock; Throwables are reported
per page (S10).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 16:16:01 +02:00
co-authored by Claude Opus 5.5
parent ede0221b50
commit d67cd0ede6
9 changed files with 1527 additions and 1 deletions
@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
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\TextReplacer;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use Throwable;
use WP_Error;
/**
* lit/divi-replace-text: replaces visible text across the Divi pages of one content type
* (spec §5.7, D10). dry_run (default true) only lists the hits with their tree_rev; the real run
* takes exactly those pages as targets (post_id + tree_rev) and needs confirm: true. Only pages
* the user may edit are searched (edit_post per page; without edit_others_* only their own).
*
* Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is
* computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
* and reported there, the others go on. Inserted text is cleaned by the field kind through
* AttrWriter::cleanValue() (S2/S28); URL and code fields are never touched.
*/
final class DiviReplaceTextAbility implements Ability
{
private const MAX_TARGETS = 20;
private const DRY_RUN_LIMIT = 10;
private const SAMPLES = 5;
public function __construct(
private ContentSchema $schema,
private DiviPageLoader $loader,
private DiviWriter $writer,
private ?\wpdb $db = null
) {
}
public function name(): string
{
return 'lit/divi-replace-text';
}
public function label(): string
{
return 'Text auf Divi-Seiten ersetzen';
}
public function description(): string
{
return 'Ersetzt sichtbaren Text (Inhalte, Titel, Button-Texte, Alt-Texte – keine URLs, Stile, Code oder '
. 'dynamischen Inhalte) auf den Divi-Seiten eines Inhaltstyps. Standard ist dry_run: true – liefert die '
. 'Treffer je Seite mit tree_rev. Zum Ersetzen dry_run: false, targets = [{post_id, tree_rev}] aus dem '
. 'Probelauf und confirm: true nach Rückfrage beim Nutzer. Jede Seite wird einzeln gespeichert und erhält '
. 'eine eigene Revision; results meldet jede Seite.';
}
public function inputSchema(): array
{
return [
'type' => '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']],
'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<string, mixed> $input
* @return array<string, mixed>|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 = [];
$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' => self::reason($problem)];
continue;
}
try {
$found = $this->replace($divi, $replacer());
} catch (InvalidArgumentException $e) {
$skipped[] = ['post_id' => $postId, 'reason' => $e->getMessage()];
continue;
}
if ($found['count'] > 0) {
$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'],
];
}
}
return ToolResult::ok(sprintf(
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s',
$total,
count($matches),
$page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.'
))
->with('dry_run', true)
->with('matches', $matches)
->with('skipped', $skipped)
->with('next_cursor', $page['next_cursor'])
->toArray();
}
/**
* @param callable(): TextReplacer $replacer
* @param array<string, mixed> $input
* @return array<string, mixed>|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'))
));
foreach ($results as $entry) {
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)->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<string, mixed>
*/
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' => self::reason($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<string, mixed>
* @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' => self::reason($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.'];
}
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 AttrWriter (field kind; markers rejected); URL and code fields are skipped.
*
* @return array{count: int, samples: list<array<string, string>>,
* blocks: list<array{Block, array<string, mixed>}>}
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
*/
private function replace(DiviPage $page, TextReplacer $replacer): array
{
$attrWriter = $this->writer->composition($page)->attrWriter();
$count = 0;
$samples = [];
$blocks = [];
foreach ($page->index->entries() as $entry) {
$block = $entry['block'];
if (! $block->isDivi() || $block->isOpaque()) {
continue;
}
$module = $block->name();
$clean = static function (string $path, ?string $key, string $value) use ($attrWriter, $module): ?string {
$kind = $attrWriter->kindOf($module, $path, $key);
return $kind === 'text' || $kind === 'html'
? (string) $attrWriter->cleanValue($module, $path, $value, $key)
: null;
};
try {
$result = $replacer->apply($block->attrs(), $clean);
} 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];
}
/**
* IDs of Divi entries whose content contains the search text as stored: raw, as
* serialize_block_attributes() escapes it (<, >, &, " and --), and additionally with \uXXXX
* for non-ASCII (WordPress' PHP serializer). Case-insensitive adds the lower and upper case
* forms (LIKE is case-insensitive for letters, not for the hex digits of \u escapes). The
* exact match happens in the tree; this is only a prefilter.
*
* @return list<int>
*/
private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
{
$db = $this->db();
$variants = $caseSensitive ? [$search] : [$search, mb_strtolower($search), mb_strtoupper($search)];
$forms = [];
foreach ($variants as $variant) {
$escaped = substr(Serializer::attributes(['s' => $variant]), 6, -2);
array_push($forms, $variant, $escaped, self::unicodeEscaped($escaped));
}
$likes = array_map(
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
array_values(array_unique($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('<!-- wp:divi/') . '%'], $likes);
$author = PostGuard::ownPostsOnly($postType);
if ($author !== null) {
$sql .= ' AND post_author = %d';
$args[] = $author;
}
$sql .= ' ORDER BY ID ASC LIMIT %d OFFSET %d';
array_push($args, $limit, $offset);
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql holds only the core posts table and one placeholder per argument.
$ids = $db->get_col($db->prepare($sql, ...$args));
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return array_map('intval', is_array($ids) ? $ids : []);
}
/** Non-ASCII characters as JSON \uXXXX escapes (UTF-16, lower-case hex like json_encode()). */
private static function unicodeEscaped(string $value): string
{
return (string) preg_replace_callback('/[^\x00-\x7F]/u', static function (array $match): string {
$units = unpack('n*', (string) mb_convert_encoding($match[0], 'UTF-16BE', 'UTF-8'));
return implode('', array_map(static fn (int $unit): string => sprintf('\\u%04x', $unit), (array) $units));
}, $value);
}
/** The client message of an error (its JSON "message"), without the JSON around it. */
private static function reason(WP_Error $error): string
{
$data = json_decode($error->get_error_message(), true);
return is_array($data) && is_string($data['message'] ?? null) ? $data['message'] : $error->get_error_message();
}
private static function log(int $postId, Throwable $e): void
{
$message = sprintf('[wp-lit-mcp] lit/divi-replace-text on post %d failed: %s', $postId, $e);
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
private function db(): \wpdb
{
if ($this->db === null) {
global $wpdb;
$this->db = $wpdb;
}
return $this->db;
}
}