From d67cd0ede6e8fac47e50938663b1173327c9e8da Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:16:01 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Divi/Compose/AttrWriter.php | 11 + .../Divi/Compose/TextReplacer.php | 324 ++++++++++++ src/Integrations/Divi/DiviIntegration.php | 2 + .../Divi/Pages/DiviReplaceTextAbility.php | 479 ++++++++++++++++++ .../Divi/Compose/AttrWriterTest.php | 14 + .../Divi/Compose/TextReplacerTest.php | 239 +++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 1 + .../Divi/Pages/DiviReplaceTextAbilityTest.php | 456 +++++++++++++++++ 9 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/Compose/TextReplacer.php create mode 100644 src/Integrations/Divi/Pages/DiviReplaceTextAbility.php create mode 100644 tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index b1942e3..7cc5165 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -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/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`, -`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. diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 9f879c6..17829ec 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -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> Breakpoint => state => value. * @throws InvalidArgumentException diff --git a/src/Integrations/Divi/Compose/TextReplacer.php b/src/Integrations/Divi/Compose/TextReplacer.php new file mode 100644 index 0000000..2de5fc2 --- /dev/null +++ b/src/Integrations/Divi/Compose/TextReplacer.php @@ -0,0 +1,324 @@ +/

Müller

' + . ' Müller'; + + $result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same()); + + self::assertSame( + '

Schmidt

' + . ' Schmidt', + self::valueOf($result['attrs']) + ); + self::assertSame(2, $result['count']); + + $unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('', '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']); + } +}