Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php
T
janniclas.loosenandClaude Opus 5.5 62ce99e558 refactor: ValueCleaner is the named choke point (H1)
The choke point moves out of AttrWriter into ValueCleaner (catalog, the
composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue),
kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with
VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits
on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged.
- ValueCleaner's docblock is the one canonical description: steps 0-8 with their
  rulings in code order, invariants I1-I3; the step comments in cleanNode carry
  the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks
  point there and keep only their own rules.
- ValueCleaner::textReplacementFor($module) holds the D10 rule that was a
  closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind).
- AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition
  builds one per composition (cleaner()).
Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf
moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner());
mixed tests call the cleaner in place; +1 test for textReplacementFor. Every
provider row and per-test assertion count unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:11:28 +02:00

480 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 LIT\MCP\Log;
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
* ValueCleaner::textReplacementFor() (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 const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
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. In reinen Textfeldern (Titel, Button-Texte) werden '
. 'Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht geändert, sondern '
. 'in skipped_nodes genannt (in der Divi-Bibliothek bearbeiten).';
}
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']],
'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<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 = [];
$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<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'))
));
$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<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' => 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<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' => 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<array<string, string>>,
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
* @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<int>
*/
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('<!-- 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 : []);
}
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
private static function opaqueReason(Block $block): string
{
return $block->isGlobal()
? self::GLOBAL_REASON
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
}
private static function log(int $postId, Throwable $e): void
{
Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e);
}
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
}