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']); + } +}