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
480 lines
20 KiB
PHP
480 lines
20 KiB
PHP
<?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'];
|
||
}
|
||
}
|