fix: Task 8 review round 1 - quote-aware tokenizer, context check, skipped global nodes (S45)
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:
@@ -39,6 +39,8 @@ final class DiviReplaceTextAbility implements Ability
|
||||
|
||||
private const SAMPLES = 5;
|
||||
|
||||
public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
|
||||
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private DiviPageLoader $loader,
|
||||
@@ -63,7 +65,9 @@ final class DiviReplaceTextAbility implements Ability
|
||||
. '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.';
|
||||
. 'eine eigene Revision; results meldet jede Seite. In reinen Textfeldern (Titel, Button-Texte) werden '
|
||||
. 'Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht geändert, sondern '
|
||||
. 'in skipped_nodes genannt (in der Divi-Bibliothek bearbeiten).';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
@@ -104,6 +108,7 @@ final class DiviReplaceTextAbility implements Ability
|
||||
'dry_run' => ['type' => 'boolean'],
|
||||
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'skipped' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'skipped_nodes' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'next_cursor' => ['type' => ['string', 'null']],
|
||||
'results' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
], ['dry_run']);
|
||||
@@ -169,6 +174,7 @@ final class DiviReplaceTextAbility implements Ability
|
||||
$page = Cursor::page($candidates, $limit, $offset);
|
||||
$matches = [];
|
||||
$skipped = [];
|
||||
$skippedNodes = [];
|
||||
$total = 0;
|
||||
foreach ($page['items'] as $postId) {
|
||||
$postId = (int) $postId;
|
||||
@@ -190,7 +196,10 @@ final class DiviReplaceTextAbility implements Ability
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($found['count'] > 0) {
|
||||
foreach ($found['skipped_nodes'] as $node) {
|
||||
$skippedNodes[] = ['post_id' => $postId] + $node;
|
||||
}
|
||||
if ($found['count'] > 0 || $found['skipped_nodes'] !== []) {
|
||||
$total += $found['count'];
|
||||
$matches[] = [
|
||||
'post_id' => $postId,
|
||||
@@ -199,19 +208,25 @@ final class DiviReplaceTextAbility implements Ability
|
||||
'tree_rev' => $divi->treeRev(),
|
||||
'count' => $found['count'],
|
||||
'samples' => $found['samples'],
|
||||
'skipped_nodes' => count($found['skipped_nodes']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult::ok(sprintf(
|
||||
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s',
|
||||
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s%s',
|
||||
$total,
|
||||
count($matches),
|
||||
$skippedNodes === [] ? '' : sprintf(
|
||||
' %d Knoten (globale Module) werden nicht geändert, siehe skipped_nodes.',
|
||||
count($skippedNodes)
|
||||
),
|
||||
$page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.'
|
||||
))
|
||||
->with('dry_run', true)
|
||||
->with('matches', $matches)
|
||||
->with('skipped', $skipped)
|
||||
->with('skipped_nodes', $skippedNodes)
|
||||
->with('next_cursor', $page['next_cursor'])
|
||||
->toArray();
|
||||
}
|
||||
@@ -263,7 +278,12 @@ final class DiviReplaceTextAbility implements Ability
|
||||
count($results),
|
||||
array_sum(array_column($replaced, 'count'))
|
||||
));
|
||||
$skippedNodes = [];
|
||||
foreach ($results as $entry) {
|
||||
foreach ($entry['skipped_nodes'] ?? [] as $node) {
|
||||
$skippedNodes[] = ['post_id' => $entry['post_id']] + $node;
|
||||
$tool->warning(sprintf('Eintrag %d, Knoten %s: %s', $entry['post_id'], $node['id'], $node['reason']));
|
||||
}
|
||||
if (! $entry['ok']) {
|
||||
$tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $entry['error']));
|
||||
continue;
|
||||
@@ -277,7 +297,10 @@ final class DiviReplaceTextAbility implements Ability
|
||||
}
|
||||
}
|
||||
|
||||
return $tool->with('dry_run', false)->with('results', $results)->toArray();
|
||||
return $tool->with('dry_run', false)
|
||||
->with('results', $results)
|
||||
->with('skipped_nodes', $skippedNodes)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +353,7 @@ final class DiviReplaceTextAbility implements Ability
|
||||
return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.'];
|
||||
}
|
||||
|
||||
$base['skipped_nodes'] = $found['skipped_nodes'];
|
||||
if ($found['count'] === 0) {
|
||||
return $base + ['ok' => true, 'count' => 0, 'tree_rev' => $page->treeRev()];
|
||||
}
|
||||
@@ -357,7 +381,7 @@ final class DiviReplaceTextAbility implements Ability
|
||||
* 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>}>}
|
||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||
*/
|
||||
private function replace(DiviPage $page, TextReplacer $replacer): array
|
||||
@@ -366,9 +390,16 @@ final class DiviReplaceTextAbility implements Ability
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
$blocks = [];
|
||||
$skipped = [];
|
||||
foreach ($page->index->entries() as $entry) {
|
||||
$block = $entry['block'];
|
||||
if (! $block->isDivi() || $block->isOpaque()) {
|
||||
if ($block->isDivi() && $block->isOpaque()) {
|
||||
if ($replacer->mentions(Serializer::block($block))) {
|
||||
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (! $block->isDivi()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -397,30 +428,22 @@ final class DiviReplaceTextAbility implements Ability
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks];
|
||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* IDs of Divi entries whose content contains the search text in one of its stored forms
|
||||
* (TextReplacer::forms()). The exact match happens in the tree; this is only a prefilter.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
|
||||
{
|
||||
$db = $this->db();
|
||||
$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));
|
||||
}
|
||||
$forms = TextReplacer::forms($search, $caseSensitive);
|
||||
$likes = array_map(
|
||||
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
|
||||
array_values(array_unique($forms))
|
||||
$forms
|
||||
);
|
||||
|
||||
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
|
||||
@@ -443,14 +466,12 @@ final class DiviReplaceTextAbility implements Ability
|
||||
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
|
||||
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
||||
private static function opaqueReason(Block $block): string
|
||||
{
|
||||
return (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);
|
||||
return $block->isGlobal()
|
||||
? self::GLOBAL_REASON
|
||||
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
|
||||
}
|
||||
|
||||
/** The client message of an error (its JSON "message"), without the JSON around it. */
|
||||
|
||||
Reference in New Issue
Block a user