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
+1 -1
View File
@@ -32,7 +32,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`). `lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`).
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
@@ -135,6 +135,17 @@ final class AttrWriter
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); 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. * @return array<string, array<string, mixed>> Breakpoint => state => value.
* @throws InvalidArgumentException * @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, "&amp" + ";", 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\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; 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\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
@@ -65,6 +66,7 @@ final class DiviIntegration implements Integration
new DiviMoveAbility($writer), new DiviMoveAbility($writer),
new DiviDeleteAbility($writer), new DiviDeleteAbility($writer),
new DiviDuplicateNodeAbility($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;
}
}
@@ -323,6 +323,20 @@ final class AttrWriterTest extends TestCase
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>')); self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
} }
/** kindOf() names the routing cleanValue() applies (lit/divi-replace-text skips url/code). */
public function testKindOfNamesTheRouting(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('html', $writer->kindOf('divi/text', 'content.innerContent'));
self::assertSame('text', $writer->kindOf('divi/heading', 'title.innerContent'));
self::assertSame('code', $writer->kindOf('divi/code', 'content.innerContent'));
self::assertSame('text', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
self::assertSame('url', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
self::assertSame('text', $writer->kindOf('divi/image', 'image.innerContent', 'alt'));
self::assertSame('url', $writer->kindOf('divi/image', 'image.innerContent', 'src'));
}
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
public function testKeysBelowScalarKindsAndUrlPathSegments(): void public function testKeysBelowScalarKindsAndUrlPathSegments(): void
{ {
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use PHPUnit\Framework\TestCase;
final class TextReplacerTest extends TestCase
{
private const ATTRS = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Seit 1987: Müller &amp; Söhne. Anruf bei MÜLLER.</p>']]],
'button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller anrufen', 'linkUrl' => '/mueller-kontakt']]]],
'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => 'Müller-Hero']]]],
'dyn' => ['innerContent' => ['desktop' => ['value' => '$variable({"type":"content","value":{"name":"Müller","settings":{}}})$']]],
];
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
/** @return callable(string, ?string, string): string */
private static function same(): callable
{
return static fn (string $p, ?string $k, string $v): string => $v;
}
/** @param array<string, mixed> $attrs */
private static function text(string $value): array
{
return ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]];
}
/** @param array<string, mixed> $attrs */
private static function valueOf(array $attrs): mixed
{
return $attrs['content']['innerContent']['desktop']['value'];
}
public function testReplacesVisibleTextOnly(): void
{
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::ATTRS, static function (string $path, ?string $key, string $value) use (&$seen): string {
$seen[] = $path . '|' . ($key ?? '');
return $value;
});
self::assertSame(4, $result['count']);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('Schmidt', $result['attrs']['title']['innerContent']['phone']['value']);
self::assertSame('<p>Seit 1987: Schmidt &amp; Söhne. Anruf bei MÜLLER.</p>', $result['attrs']['content']['innerContent']['desktop']['value']);
self::assertSame(['text' => 'Schmidt anrufen', 'linkUrl' => '/mueller-kontakt'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame('Müller-Hero', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
self::assertSame(self::ATTRS['dyn'], $result['attrs']['dyn']);
self::assertContains('button.innerContent|text', $seen);
self::assertSame(['path' => 'button.innerContent.text', 'before' => 'Müller anrufen', 'after' => 'Schmidt anrufen'], $result['samples'][0]);
}
public function testCaseInsensitiveIncludingUmlauts(): void
{
$result = (new TextReplacer('müller', 'Schmidt', false))->apply(self::ATTRS, self::same());
self::assertSame(5, $result['count']);
self::assertStringContainsString('Anruf bei Schmidt.', $result['attrs']['content']['innerContent']['desktop']['value']);
}
public function testNothingFound(): void
{
$result = (new TextReplacer('Meier', 'X'))->apply(self::ATTRS, self::same());
self::assertSame(0, $result['count']);
self::assertSame(self::ATTRS, $result['attrs']);
}
/** Ruling S8: tags with their attributes, entities and references are never touched. */
public function testTagsEntitiesAndReferencesStayUntouched(): void
{
$value = '<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Kontakt</a> &amp; '
. 'kontakt@kunde.test ' . self::REF . ' amp</p>';
$result = (new TextReplacer('kontakt', 'Anfrage', false))->apply(self::text($value), self::same());
self::assertSame(
'<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Anfrage</a> &amp; '
. 'Anfrage@kunde.test ' . self::REF . ' amp</p>',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$entity = (new TextReplacer('amp', 'X'))->apply(self::text($value), self::same());
self::assertStringContainsString('&amp; ', (string) self::valueOf($entity['attrs']));
self::assertStringEndsWith(self::REF . ' X</p>', (string) self::valueOf($entity['attrs']));
self::assertSame(1, $entity['count']);
$inside = (new TextReplacer('post_title', 'post_date'))->apply(self::text('Titel: ' . self::REF), self::same());
self::assertSame(0, $inside['count'], 'text inside an embedded reference is not replaced');
}
/** Script and style bodies are code, not visible text (D10: never styles). */
public function testScriptAndStyleBodiesAreNotText(): void
{
$value = '<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Müller</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Müller';
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same());
self::assertSame(
'<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Schmidt</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Schmidt',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('<script>x("Müller")'), self::same());
self::assertSame(0, $unclosed['count'], 'an unclosed script runs to the end of the value');
}
/** The clean callback gets the replacement for each place; its result is what is inserted. */
public function testInsertsTheCleanedReplacementPerPlace(): void
{
$calls = [];
$result = (new TextReplacer('Müller', '<b>Schmidt</b>'))->apply(
self::ATTRS,
static function (string $path, ?string $key, string $value) use (&$calls): string {
$calls[] = $path . '|' . ($key ?? '');
return $path === 'title.innerContent' ? strip_tags($value) : $value;
}
);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('<p>Seit 1987: <b>Schmidt</b> &amp; Söhne. Anruf bei MÜLLER.</p>', self::valueOf($result['attrs']));
self::assertSame(['button.innerContent|text', 'content.innerContent|', 'title.innerContent|'], $calls, 'once per place');
}
/** A place the callback refuses (null: URL or code field) stays as it is. */
public function testNullFromTheCallbackLeavesThePlace(): void
{
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(
self::ATTRS,
static fn (string $p, ?string $k, string $v): ?string => $p === 'content.innerContent' ? null : $v
);
self::assertSame(3, $result['count']);
self::assertSame(self::ATTRS['content'], $result['attrs']['content']);
}
/** Forward risk (Task 1): unchanged strings are never handed to the callback and stay byte for byte. */
public function testUnchangedStringsAreNeverCleaned(): void
{
$odd = '$variable({"type":"content","value":{"name":"not_in_the_allow_list","settings":{"x":"<b>"}}})$';
$attrs = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => $odd], 'tablet' => ['value' => " a\t<br>b %41 "]]],
];
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply($attrs, static function (string $p, ?string $k, string $v) use (&$seen): string {
$seen[] = $p;
return $v;
});
self::assertSame(['title.innerContent'], $seen);
self::assertSame($attrs['content'], $result['attrs']['content']);
}
/**
* Ruling S8 hardening: a result whose tags, entities or references differ from the original
* ones plus the inserted replacement's own is refused – nothing is changed.
*
* @return array<string, array{string, string, string}>
*/
public static function crossBoundary(): array
{
return [
'replacement closes a lone "<" into a tag' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>'],
'replacement opens a tag closed by a lone ">"' => ['Müller c>', 'Müller', '<img src=x onerror=alert(1) '],
'replacement completes an entity' => ['Müller;', 'Müller', '&amp'],
'removal glues an entity together' => ['&amp--; text', '--', ''],
'replacement closes a partial reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{}}} END', 'END', ')$'],
'nested reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$', 'Müller', 'x'],
'reference across a line break' => ["\$variable({\"type\":\"content\",\n\"value\":{\"name\":\"post_title\"}})$ Müller", 'Müller', 'x'],
'removal glues a marker' => ['$varia--ble( Müller', '--', ''],
];
}
/** @dataProvider crossBoundary */
public function testRefusesResultsThatChangeStructure(string $value, string $search, string $replace): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('content.innerContent');
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
}
public function testRefusesAMarkerFromTheCallback(): void
{
$this->expectException(InvalidArgumentException::class);
(new TextReplacer('Müller', 'x'))->apply(self::text('Müller'), static fn (): string => self::REF);
}
/** @return array<string, array{string, string, string}> */
public static function badInput(): array
{
return [
'short search' => ['M', 'x', 'search'],
'empty search' => ['', 'x', 'search'],
'search with marker' => ['$variable(', 'x', 'search'],
'invalid UTF-8 search' => ["M\xC3", 'x', 'search'],
'invalid UTF-8 replacement' => ['Müller', "x\xC3", 'replace'],
'replacement is a reference' => ['Müller', self::REF, 'replace'],
'replacement embeds a reference' => ['Müller', 'Hallo ' . self::REF . ' Welt', 'replace'],
'tag-split marker (S28)' => ['Müller', '$variable<x>({"type":"content","value":{"name":"post_title"}})$', 'replace'],
'nested tag-split marker' => ['Müller', '$vari<<x>x>able(', 'replace'],
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
];
}
/** @dataProvider badInput */
public function testProblemsNameTheField(string $search, string $replace, string $field): void
{
self::assertArrayHasKey($field, TextReplacer::problems($search, $replace));
$this->expectException(InvalidArgumentException::class);
new TextReplacer($search, $replace);
}
public function testFineInputHasNoProblems(): void
{
self::assertSame([], TextReplacer::problems('Müller', ''));
self::assertSame([], TextReplacer::problems('$5', 'Preis (netto) $'));
}
}
@@ -24,6 +24,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-move', 'lit/divi-move',
'lit/divi-delete', 'lit/divi-delete',
'lit/divi-duplicate-node', 'lit/divi-duplicate-node',
'lit/divi-replace-text',
]; ];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
@@ -0,0 +1,456 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
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\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
final class DiviReplaceTextAbilityTest extends TestCase
{
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
private WP_Post $post;
/** @var list<string> */
private array $saved = [];
/** @var list<string> */
private array $queries = [];
/** @var list<Throwable> Thrown by the saver, one per call, before it saves normally. */
private array $failures = [];
/** @param list<int> $ids Rows of the prefilter query. */
private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): DiviReplaceTextAbility
{
$this->post = DiviPageLoaderTest::givenPost($content ?? DiviPageLoaderTest::fixture('synthetic-basic.html'));
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$schema = DiviPageLoaderTest::schema();
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer($unfiltered), ComposeFixture::media());
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array {
$failure = array_shift($this->failures);
if ($failure !== null) {
throw $failure;
}
$this->saved[] = $content;
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(4711);
$loader = new DiviPageLoader($schema);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array {
$this->queries[] = $sql;
return $ids;
});
return new DiviReplaceTextAbility(
$schema,
$loader,
new DiviWriter($loader, $saver, $log, $design, $composition),
$db
);
}
/**
* A page with one section, row and column holding $modules.
*
* @param list<array{string, array<string, mixed>}> $modules Block name and attributes.
*/
private static function page(array $modules): string
{
$inner = '';
foreach ($modules as [$name, $attrs]) {
$inner .= '<!-- wp:' . $name . ' ' . Serializer::attributes($attrs) . ' --><!-- /wp:' . $name . ' -->';
}
return '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} --><!-- wp:divi/row '
. '{"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
}
/** @return array{string, array<string, mixed>} */
private static function text(string $html): array
{
return ['divi/text', ['content' => ['innerContent' => ['desktop' => ['value' => $html]]], 'builderVersion' => '5.2.1']];
}
/** @return array{string, array<string, mixed>} */
private static function heading(string $title): array
{
return ['divi/heading', ['title' => ['innerContent' => ['desktop' => ['value' => $title]]], 'builderVersion' => '5.2.1']];
}
/** @param array<string, mixed> $input */
private function replaceFor(DiviReplaceTextAbility $ability, array $input): mixed
{
return $ability->execute($input + [
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => Rev::of((string) $this->post->post_content)]],
]);
}
/** Desktop value of $path in node $id of the last saved content. */
private function savedValue(string $id, string $path): mixed
{
$block = NodeIndex::build((new Parser())->parse($this->saved[count($this->saved) - 1]))->find($id);
return $block === null ? null : AttrMap::desktop($block->attrs(), $path);
}
/** @return array<string, mixed> */
private static function clientError(WP_Error $error): array
{
return (array) json_decode($error->get_error_message(), true);
}
public function testDryRunListsHitsWithTreeRevAndChangesNothing(): void
{
$ability = $this->ability();
$result = $ability->execute(['search' => 'Termin', 'replace' => 'Beratung']);
self::assertTrue($result['dry_run']);
self::assertSame([], $this->saved);
self::assertSame(7, $result['matches'][0]['post_id']);
self::assertSame(Rev::of($this->post->post_content), $result['matches'][0]['tree_rev']);
self::assertSame(1, $result['matches'][0]['count']);
self::assertSame('s0.r0.c1.m2', $result['matches'][0]['samples'][0]['id']);
self::assertSame('Beratung buchen', $result['matches'][0]['samples'][0]['after']);
self::assertNull($result['next_cursor']);
self::assertStringContainsString("post_type = 'page'", $this->queries[0]);
self::assertStringContainsString("LIKE '%<!-- wp:divi/%'", $this->queries[0]);
self::assertStringContainsString("LIKE '%Termin%'", $this->queries[0]);
self::assertStringNotContainsString('post_author', $this->queries[0]);
self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->isDirect());
}
public function testPrefilterCoversStoredAndJsonEscapedForms(): void
{
$ability = $this->ability();
$ability->execute(['search' => 'Müller & Co', 'replace' => 'X', 'case_sensitive' => false]);
$sql = $this->queries[0];
self::assertStringContainsString("LIKE '%Müller & Co%'", $sql);
self::assertStringContainsString("LIKE '%Müller \\\\u0026 Co%'", $sql, 'serialize_block_attributes form');
self::assertStringContainsString("LIKE '%M\\\\u00fcller \\\\u0026 Co%'", $sql, 'unicode-escaped form');
self::assertStringContainsString("LIKE '%M\\\\u00dcLLER \\\\u0026 CO%'", $sql, 'upper case, case-insensitive');
}
public function testDryRunPagesWithACursorAndOnlyOwnPagesWithoutEditOthers(): void
{
$ability = $this->ability(null, false, [7, 8]);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_pages');
Functions\when('get_current_user_id')->justReturn(5);
$result = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 1]);
self::assertCount(1, $result['matches']);
self::assertIsString($result['next_cursor']);
self::assertStringContainsString('post_author = 5', $this->queries[0]);
self::assertStringContainsString('LIMIT 2 OFFSET 0', $this->queries[0]);
}
public function testDryRunLeavesOutPagesTheUserCannotEdit(): void
{
$ability = $this->ability();
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
$result = $ability->execute(['search' => 'Termin', 'replace' => 'X']);
self::assertSame([], $result['matches']);
self::assertSame([], $result['skipped']);
}
public function testRealRunNeedsTargetsAndConfirm(): void
{
$ability = $this->ability();
self::assertSame('lit_validation_failed', $ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false])->get_error_code());
self::assertSame('lit_validation_failed', $ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7]],
])->get_error_code());
self::assertSame('lit_confirm_required', $ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'targets' => [['post_id' => 7, 'tree_rev' => 'abc']],
])->get_error_code());
self::assertSame([], $this->saved);
}
public function testReplacesPerPageAndReportsStaleOnes(): void
{
$ability = $this->ability();
$original = (string) $this->post->post_content;
$treeRev = Rev::of($original);
$result = $ability->execute([
'search' => 'termin',
'replace' => 'Rückruf',
'case_sensitive' => false,
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]],
]);
self::assertFalse($result['dry_run']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('"text":"Rückruf buchen"', $this->saved[0]);
self::assertTrue($result['results'][0]['ok']);
self::assertSame(1, $result['results'][0]['count']);
self::assertSame(811, $result['results'][0]['revision_id']);
self::assertSame(Rev::of($this->saved[0]), $result['results'][0]['tree_rev']);
self::assertFalse($result['results'][1]['ok'], 'second target has a stale tree_rev now');
self::assertStringContainsString('tree_rev passt nicht', $result['results'][1]['error']);
self::assertSame([['id' => '7', 'type' => 'page', 'action' => 'replaced', 'label' => 'Start']], $result['changed']);
self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]);
// Byte identity: only the button's opener changed.
self::assertSame(str_replace('"text":"Termin buchen"', '"text":"Rückruf buchen"', $original), $this->saved[0]);
}
public function testRejectsBadInputBeforeAnyPage(): void
{
$ability = $this->ability();
$short = $ability->execute(['search' => 'T', 'replace' => 'X']);
$reference = $ability->execute(['search' => 'Termin', 'replace' => self::REF, 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7, 'tree_rev' => 'x']]]);
self::assertSame('lit_validation_failed', $short->get_error_code());
self::assertArrayHasKey('search', self::clientError($short)['fields']);
self::assertSame('lit_validation_failed', $reference->get_error_code());
self::assertArrayHasKey('replace', self::clientError($reference)['fields']);
self::assertSame([], $this->queries);
self::assertSame([], $this->saved);
}
/** Ruling S8 end to end: links, classes, entities and embedded references stay as stored. */
public function testLinksEntitiesAndReferencesSurviveASave(): void
{
$html = '<p class="kontakt"><a href="/kontakt" class="kontakt-link">Kontakt</a> &amp; ' . self::REF . ' kontakt</p>';
$ability = $this->ability(self::page([self::text($html)]));
$result = $this->replaceFor($ability, ['search' => 'kontakt', 'replace' => 'Anfrage', 'case_sensitive' => false]);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame(2, $result['results'][0]['count']);
self::assertSame(
'<p class="kontakt"><a href="/kontakt" class="kontakt-link">Anfrage</a> &amp; ' . self::REF . ' Anfrage</p>',
$this->savedValue('s0.r0.c0.m0', 'content.innerContent')
);
}
/** The inserted text is cleaned by the field kind: stripped in text fields, kses'd in rich text. */
public function testInsertedTextIsCleanedByFieldKind(): void
{
$ability = $this->ability(self::page([
self::heading('Willkommen bei Müller'),
self::text('<p>Müller <iframe src="https://video.test/1"></iframe></p>'),
]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => '<em>Schmidt</em><script>alert(1)</script><iframe src=x></iframe>']);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame('Willkommen bei Schmidt', $this->savedValue('s0.r0.c0.m0', 'title.innerContent'));
self::assertSame(
'<p><em>Schmidt</em> <iframe src="https://video.test/1"></iframe></p>',
$this->savedValue('s0.r0.c0.m1', 'content.innerContent'),
'only the inserted text is cleaned; the stored iframe stays'
);
self::assertStringNotContainsString('script', $this->saved[0]);
}
public function testAdministratorsKeepTheirMarkup(): void
{
$ability = $this->ability(self::page([self::text('<p>Müller</p>')]), true);
$this->replaceFor($ability, ['search' => 'Müller', 'replace' => '<iframe src="https://video.test/2"></iframe>']);
self::assertSame('<p><iframe src="https://video.test/2"></iframe></p>', $this->savedValue('s0.r0.c0.m0', 'content.innerContent'));
}
/** Forward risk (Task 1): stored references outside the allow list are never re-cleaned. */
public function testUnchangedValuesAreNeverRecleaned(): void
{
$odd = '$variable({"type":"content","value":{"name":"some_plugin_field","settings":{"enable_html":"on"}}})$';
$heading = self::heading('Müller');
$heading[1]['module'] = ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '$variable({"type":"color","value":{"name":"gcid-unknown","settings":{}}})$']]]]];
$ability = $this->ability(self::page([$heading, self::text('<p>' . $odd . '</p>'), self::text('<p>Müller</p>')]));
$original = (string) $this->post->post_content;
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame(2, $result['results'][0]['count']);
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-unknown","settings":{}}})$',
$this->savedValue('s0.r0.c0.m0', 'module.decoration.background')['color']
);
$untouched = '<!-- wp:divi/text ' . Serializer::attributes(self::text('<p>' . $odd . '</p>')[1]) . ' -->';
self::assertStringContainsString($untouched, $original);
self::assertStringContainsString($untouched, $this->saved[0], 'a block without hits keeps every byte');
}
public function testUrlAndCodeFieldsAreNeverTouched(): void
{
$ability = $this->ability(self::page([
['divi/code', ['content' => ['innerContent' => ['desktop' => ['value' => '<div>Müller</div>']]]]],
['divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller', 'linkUrl' => '/Müller']]]]]],
]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
self::assertSame(1, $result['results'][0]['count']);
self::assertSame('<div>Müller</div>', $this->savedValue('s0.r0.c0.m0', 'content.innerContent'));
self::assertSame(['text' => 'Schmidt', 'linkUrl' => '/Müller'], $this->savedValue('s0.r0.c0.m1', 'button.innerContent'));
}
/**
* Ruling S6 (Task 8): every case ends in an error or no change – and nothing is stored.
*
* @return array<string, array{string, string, string, bool}> Stored rich text, search, replacement, unfiltered_html.
*/
public static function malicious(): array
{
$tagSplit = '$variable<x>({"type":"content","value":{"name":"post_title","settings":{}}})$';
return [
'replacement is a reference' => ['<p>Müller</p>', 'Müller', self::REF, false],
'replacement is a reference (admin)' => ['<p>Müller</p>', 'Müller', self::REF, true],
'replacement embeds a reference' => ['<p>Müller</p>', 'Müller', 'Hallo ' . self::REF, true],
'tag-split marker' => ['<p>Müller</p>', 'Müller', $tagSplit, false],
'tag-split marker (admin)' => ['<p>Müller</p>', 'Müller', $tagSplit, true],
'NUL-split marker' => ['<p>Müller</p>', 'Müller', "\$vari\0able(", false],
'VT-split tag' => ['<p>Müller</p>', 'Müller', "\$variable<\x0B>(", false],
'octet-split marker' => ['<p>Müller</p>', 'Müller', '$varia%61ble(', false],
'search for the marker' => ['<p>' . self::REF . '</p>', '$variable(', 'x', true],
'search cuts a reference' => ['<p>' . self::REF . '</p>', '$variable', 'x', true],
'search for the reference end' => ['<p>' . self::REF . '</p>', ')$', 'x', true],
'search inside a reference' => ['<p>' . self::REF . '</p>', 'post_title', 'post_date', true],
'search inside a tag' => ['<p><a href="/kontakt" onclick="x">Hallo</a></p>', 'kontakt', 'javascript:alert(1)', true],
'replacement closes a partial reference' => ['<p>$variable({"type":"content","value":{"name":"post_title","settings":{}}} ENDE</p>', 'ENDE', ')$', true],
'removal closes a partial marker' => ['<p>$varia--ble({"type":"content","value":{"name":"post_title","settings":{}}})$</p>', '--', '', true],
'replacement closes a lone "<"' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>', true],
'replacement opens a tag up to a lone ">"' => ['<p>Müller c></p>', 'Müller', '<img src=x onerror=alert(1) ', false],
'replacement completes an entity' => ['<p>Müller;</p>', 'Müller', '&lt', true],
'nested reference' => ['<p>$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$</p>', 'Müller', 'x', true],
'script body' => ['<script>var n = "Müller";</script>', 'Müller', '";alert(1);//', false],
];
}
/** @dataProvider malicious */
public function testMaliciousReplacementsStoreNothing(string $html, string $search, string $replace, bool $unfiltered): void
{
$ability = $this->ability(self::page([self::text($html)]), $unfiltered);
$dry = $ability->execute(['search' => $search, 'replace' => $replace]);
$result = $this->replaceFor($ability, ['search' => $search, 'replace' => $replace]);
self::assertSame([], $this->saved);
if ($result instanceof WP_Error) {
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertInstanceOf(WP_Error::class, $dry);
return;
}
$entry = $result['results'][0];
self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry));
self::assertSame([], $dry['matches'], 'the dry run shows no hit either');
}
/** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */
public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void
{
$ability = $this->ability();
$treeRev = Rev::of((string) $this->post->post_content);
$this->failures = [new RuntimeException('SQLSTATE secret detail')];
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $log);
try {
$result = $ability->execute([
'search' => 'Termin',
'replace' => 'Rückruf',
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]],
]);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertFalse($result['results'][0]['ok']);
self::assertStringNotContainsString('SQLSTATE', $result['results'][0]['error']);
self::assertStringContainsString('Interner Fehler', $result['results'][0]['error']);
self::assertTrue($result['results'][1]['ok']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('SQLSTATE secret detail', (string) file_get_contents($log));
unlink($log);
}
public function testWriteFailedKeepsItsOwnMessage(): void
{
$ability = $this->ability();
$this->failures = [new WriteFailed('Die Datenbank hat das Speichern abgelehnt.', true, 7)];
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $log);
try {
$result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => 'Rückruf']);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertFalse($result['results'][0]['ok']);
self::assertStringContainsString('Die Datenbank hat das Speichern abgelehnt.', $result['results'][0]['error']);
self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]);
unlink($log);
}
/** A refused string makes its whole page fail (all or nothing per page); other pages go on. */
public function testARefusedStringFailsOnlyItsPage(): void
{
$ability = $this->ability(self::page([self::heading('Müller'), self::text('a < b Müller')]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'x>']);
self::assertSame([], $this->saved);
self::assertFalse($result['results'][0]['ok']);
self::assertStringContainsString('s0.r0.c0.m1', $result['results'][0]['error']);
self::assertStringContainsString('content.innerContent', $result['results'][0]['error']);
}
}