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:
@@ -135,6 +135,17 @@ final class AttrWriter
|
||||
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind cleanValue() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text
|
||||
* uses it to leave URL and code fields alone (D10).
|
||||
*
|
||||
* @return 'html'|'code'|'url'|'text'
|
||||
*/
|
||||
public function kindOf(string $module, string $path, ?string $subKey = null): string
|
||||
{
|
||||
return $this->kind($module, $path, $subKey, $subKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> Breakpoint => state => value.
|
||||
* @throws InvalidArgumentException
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
|
||||
/**
|
||||
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
|
||||
* only in content (…innerContent) – plain values and the text keys of object values (text,
|
||||
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
|
||||
* Pure PHP.
|
||||
*
|
||||
* Ruling S8: each string is split into tags, entities and references (kept byte for byte) and
|
||||
* plain text; only plain text outside <script>/<style> bodies is searched. The inserted text is
|
||||
* the replacement as the $clean callback returns it for that place (AttrWriter::cleanValue() in
|
||||
* the ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
||||
* hit is returned as it is and never cleaned (Task 1 forward risk: stored references outside
|
||||
* the allow list must survive). A changed string must have exactly the tags, entities and
|
||||
* references of the original plus those of the inserted text – otherwise the replacement glued
|
||||
* something new together across a boundary (a lone "<" closed into a tag, "&" + ";", a
|
||||
* partial reference closed by ")$") and the whole call is refused (InvalidArgumentException).
|
||||
*/
|
||||
final class TextReplacer
|
||||
{
|
||||
/** Ruling S8, verbatim: tags, entities and references are delimiters, the rest is plain text. */
|
||||
public const SPLIT = '/(<[^>]*>|&[#A-Za-z0-9]+;|\$variable\(.+?\)\$)/';
|
||||
|
||||
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
||||
|
||||
/** Elements whose body is code, not text. */
|
||||
private const RAW_OPEN = '/^<(script|style)\b/i';
|
||||
|
||||
private const CONTEXT = 30;
|
||||
|
||||
private string $pattern;
|
||||
|
||||
/** @var array<string, ?string> Cleaned replacement per place (path|key). */
|
||||
private array $cleaned = [];
|
||||
|
||||
/** @throws InvalidArgumentException When problems() finds any. */
|
||||
public function __construct(private string $search, private string $replace, private bool $caseSensitive = true)
|
||||
{
|
||||
$problems = self::problems($search, $replace);
|
||||
if ($problems !== []) {
|
||||
throw new InvalidArgumentException(implode(' ', $problems));
|
||||
}
|
||||
|
||||
$this->pattern = '/' . preg_quote($search, '/') . '/u' . ($caseSensitive ? '' : 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Input problems, field => message (empty when fine). The replacement must neither be nor
|
||||
* contain a reference (rulings S2/D9) – also not one a sanitizer would build by stripping
|
||||
* control characters, tags or %XX octets (S28/S32a); the search must not contain the marker.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function problems(string $search, string $replace): array
|
||||
{
|
||||
$problems = [];
|
||||
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
|
||||
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
|
||||
} elseif (VariableRef::contains(VariableRef::withoutControls($search))) {
|
||||
$problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden '
|
||||
. 'nie ersetzt.';
|
||||
}
|
||||
|
||||
if (! mb_check_encoding($replace, 'UTF-8')) {
|
||||
$problems['replace'] = 'Ersetzung ist kein gültiges UTF-8.';
|
||||
} elseif (VariableRef::contains(self::stripped($replace))) {
|
||||
$problems['replace'] = 'Ersetzung darf keinen $variable(…)$-Verweis enthalten (auch nicht zerlegt); '
|
||||
. 'dynamische Inhalte nur über lit/divi-update einzeln setzen.';
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
|
||||
* The replacement as it may be inserted at that place, or null when the place is no text
|
||||
* field (URL, code) and stays as it is. May throw InvalidArgumentException.
|
||||
* @return array{attrs: array<string, mixed>, count: int,
|
||||
* samples: list<array{path: string, before: string, after: string}>}
|
||||
* @throws InvalidArgumentException When a replacement would change markup or references.
|
||||
*/
|
||||
public function apply(array $attrs, callable $clean): array
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
foreach (AttrMap::flatten($attrs) as $path => $map) {
|
||||
// A key containing "." would make the dotted path address something else.
|
||||
$content = str_ends_with($path, '.innerContent') && AttrMap::isBreakpointMap($map);
|
||||
if (! $content || AttrMap::get($attrs, $path) !== $map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($map as $breakpoint => $states) {
|
||||
foreach (is_array($states) ? $states : [] as $state => $value) {
|
||||
$replaced = $this->value($value, $path, $clean, $count, $samples);
|
||||
if ($replaced !== $value) {
|
||||
$attrs = AttrMap::set($attrs, sprintf('%s.%s.%s', $path, $breakpoint, $state), $replaced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['attrs' => $attrs, 'count' => $count, 'samples' => $samples];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, ?string, string): ?string $clean
|
||||
* @param list<array{path: string, before: string, after: string}> $samples
|
||||
*/
|
||||
private function value(mixed $value, string $path, callable $clean, int &$count, array &$samples): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->text($value, $path, null, $clean, $count, $samples);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach (self::TEXT_KEYS as $key) {
|
||||
if (is_string($value[$key] ?? null)) {
|
||||
$value[$key] = $this->text($value[$key], $path, $key, $clean, $count, $samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, ?string, string): ?string $clean
|
||||
* @param list<array{path: string, before: string, after: string}> $samples
|
||||
*/
|
||||
private function text(
|
||||
string $value,
|
||||
string $path,
|
||||
?string $key,
|
||||
callable $clean,
|
||||
int &$count,
|
||||
array &$samples
|
||||
): string {
|
||||
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
if (! is_array($parts)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$hits = $this->hits($parts);
|
||||
if ($hits === null || array_sum($hits) === 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$place = $path . ($key === null ? '' : '.' . $key);
|
||||
$replacement = $this->replacement($path, $key, $clean);
|
||||
if ($replacement === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$expected = [];
|
||||
$insert = static fn (): string => $replacement;
|
||||
$replacementDelimiters = self::delimiters($replacement);
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1) {
|
||||
$expected[] = $part;
|
||||
} elseif (($hits[$i] ?? 0) > 0) {
|
||||
$parts[$i] = (string) preg_replace_callback($this->pattern, $insert, $part);
|
||||
for ($n = 0; $n < $hits[$i]; $n++) {
|
||||
array_push($expected, ...$replacementDelimiters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$replaced = implode('', $parts);
|
||||
// Every marker of the original must be a whole reference (else the string holds a partial
|
||||
// or nested one Divi might read differently), none may be added, and the structure must
|
||||
// be the original's plus the inserted text's own.
|
||||
$markers = substr_count($value, VariableRef::MARKER);
|
||||
$wholeReferences = $markers === self::referenceCount($parts);
|
||||
$sameMarkers = substr_count($replaced, VariableRef::MARKER) === $markers;
|
||||
if (! $wholeReferences || ! $sameMarkers || self::delimiters($replaced) !== $expected) {
|
||||
throw self::refused($place);
|
||||
}
|
||||
|
||||
$count += array_sum($hits);
|
||||
if (count($samples) < 5) {
|
||||
$samples[] = [
|
||||
'path' => $place,
|
||||
'before' => $this->excerpt(NodeSummary::textOf($value), $this->search),
|
||||
'after' => $this->excerpt(NodeSummary::textOf($replaced), $replacement),
|
||||
];
|
||||
}
|
||||
|
||||
return $replaced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hits per plain part (index => count); plain text inside <script>/<style> has none. Null
|
||||
* when a part cannot be searched (invalid UTF-8): the string stays as it is.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
private function hits(array $parts): ?array
|
||||
{
|
||||
$hits = [];
|
||||
$raw = null;
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1) {
|
||||
if ($raw === null && preg_match(self::RAW_OPEN, $part, $match) === 1) {
|
||||
$raw = strtolower($match[1]);
|
||||
} elseif ($raw !== null && preg_match('#^</' . $raw . '\b#i', $part) === 1) {
|
||||
$raw = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($raw !== null || $part === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found = preg_match_all($this->pattern, $part);
|
||||
if ($found === false) {
|
||||
return null;
|
||||
}
|
||||
$hits[$i] = $found;
|
||||
}
|
||||
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/** @param callable(string, ?string, string): ?string $clean */
|
||||
private function replacement(string $path, ?string $key, callable $clean): ?string
|
||||
{
|
||||
$place = $path . '|' . ($key ?? '');
|
||||
if (! array_key_exists($place, $this->cleaned)) {
|
||||
$cleaned = $clean($path, $key, $this->replace);
|
||||
if ($cleaned !== null && VariableRef::contains($cleaned)) {
|
||||
throw VariableRef::notAlone();
|
||||
}
|
||||
$this->cleaned[$place] = $cleaned;
|
||||
}
|
||||
|
||||
return $this->cleaned[$place];
|
||||
}
|
||||
|
||||
/** @return list<string> Tags, entities and references of $value, in order. */
|
||||
private static function delimiters(string $value): array
|
||||
{
|
||||
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
return is_array($parts)
|
||||
? array_values(array_filter($parts, static fn (int $i): bool => $i % 2 === 1, ARRAY_FILTER_USE_KEY))
|
||||
: [$value];
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
private static function referenceCount(array $parts): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1 && str_starts_with($part, VariableRef::MARKER)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* $value as a sanitizer could leave it: controls gone, then tags (nested like strip_tags
|
||||
* counts them, an unclosed one up to the end) and %XX octets removed until nothing changes.
|
||||
*/
|
||||
private static function stripped(string $value): string
|
||||
{
|
||||
$value = VariableRef::withoutControls($value);
|
||||
do {
|
||||
$before = $value;
|
||||
$kept = '';
|
||||
$depth = 0;
|
||||
foreach (str_split($value) as $char) {
|
||||
if ($char === '<') {
|
||||
$depth++;
|
||||
} elseif ($depth > 0) {
|
||||
$depth -= $char === '>' ? 1 : 0;
|
||||
} else {
|
||||
$kept .= $char;
|
||||
}
|
||||
}
|
||||
$value = (string) preg_replace('/%[0-9A-Fa-f]{2}/', '', $kept);
|
||||
} while ($value !== $before);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function refused(string $place): InvalidArgumentException
|
||||
{
|
||||
return new InvalidArgumentException(sprintf(
|
||||
'"%s": die Ersetzung würde dort HTML-Tags, Entities oder $variable(…)$-Verweise verändern oder neu '
|
||||
. 'bilden (Text grenzt an ein einzelnes "<", ">", "&" oder einen unvollständigen Verweis); nichts '
|
||||
. 'wurde ersetzt.',
|
||||
$place
|
||||
));
|
||||
}
|
||||
|
||||
private function excerpt(string $text, string $needle): string
|
||||
{
|
||||
$position = $needle === '' ? false
|
||||
: ($this->caseSensitive ? mb_strpos($text, $needle) : mb_stripos($text, $needle));
|
||||
if ($position === false) {
|
||||
return mb_substr($text, 0, 2 * self::CONTEXT);
|
||||
}
|
||||
|
||||
$start = max(0, $position - self::CONTEXT);
|
||||
$length = mb_strlen($needle) + 2 * self::CONTEXT;
|
||||
|
||||
return ($start > 0 ? '…' : '') . mb_substr($text, $start, $length)
|
||||
. ($start + $length < mb_strlen($text) ? '…' : '');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
@@ -65,6 +66,7 @@ final class DiviIntegration implements Integration
|
||||
new DiviMoveAbility($writer),
|
||||
new DiviDeleteAbility($writer),
|
||||
new DiviDuplicateNodeAbility($writer),
|
||||
new DiviReplaceTextAbility($this->schema, $loader, $writer),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user