diff --git a/README.md b/README.md index 5011a2d..00d08b0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. ## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1) -**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `
` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php scripts/repair-doubled-entities.php` auf (erst Trockenlauf, dann `--schreiben`). +**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `
` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie. ## Eigenes HTML und eingebettete Seiten (ab 1.20.0) diff --git a/docs/STATUS.md b/docs/STATUS.md index c935d96..50b076e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,9 +8,9 @@ **Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern. -**Bestand.** `scripts/repair-doubled-entities.php` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live. +**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live. -**Geprüft:** neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt. +**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt. **Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt. diff --git a/scripts/repair-doubled-entities.php b/scripts/repair-doubled-entities.php deleted file mode 100644 index 4e6c99e..0000000 --- a/scripts/repair-doubled-entities.php +++ /dev/null @@ -1,127 +0,0 @@ - $block) { - foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) { - $wert = $block['props'][$feld] ?? null; - if (!is_string($wert) || '' === $wert) { - continue; - } - $neu = $entschachteln($wert); - if ($neu !== $wert) { - $liste[$index]['props'][$feld] = $neu; - ++$zahl; - if (count($beispiele) < 6) { - $beispiele[] = mb_substr($wert, 0, 60).' → '.mb_substr($neu, 0, 60); - } - } - } - if (isset($block['children']) && is_array($block['children'])) { - $liste[$index]['children'] = $bausteine($block['children'], $zahl, $beispiele); - } - } - - return $liste; -}; - -$gesamt = 0; -$berichte = []; - -// Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf. -$aktive = []; -foreach ($db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) { - foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) { - if (!empty($seite[$spalte])) { - $aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')'; - } - } -} -foreach ($aktive as $revision => $bezeichnung) { - $roh = $db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]); - if (!is_string($roh)) { - continue; - } - $dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR); - $zahl = 0; - $beispiele = []; - $dokument['blocks'] = $bausteine($dokument['blocks'] ?? [], $zahl, $beispiele); - if (0 === $zahl) { - continue; - } - $gesamt += $zahl; - $berichte[] = ['was' => $bezeichnung, 'zahl' => $zahl, 'beispiele' => $beispiele]; - if ($schreiben) { - // Geprüft wird wie beim Speichern - eine Korrektur darf kein ungültiges Dokument hinterlassen. - $validator->validate($dokument, writing: true); - $db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $revision]); - } -} - -// Und die Bereiche (Kopf, Fuß, verknüpfte Sektionen). -foreach ($db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) { - $dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR); - $zahl = 0; - $beispiele = []; - $dokument['blocks'] = $bausteine($dokument['blocks'] ?? [], $zahl, $beispiele); - if (0 === $zahl) { - continue; - } - $gesamt += $zahl; - $berichte[] = ['was' => 'Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', 'zahl' => $zahl, 'beispiele' => $beispiele]; - if ($schreiben) { - $validator->validate($dokument, writing: true); - $db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $bereich['id']]); - } -} - -foreach ($berichte as $bericht) { - echo $bericht['was'], ': ', $bericht['zahl'], ' Stelle(n)', "\n"; - foreach ($bericht['beispiele'] as $beispiel) { - echo ' ', $beispiel, "\n"; - } -} -if (0 === $gesamt) { - echo "Nichts zu reparieren.\n"; - exit(0); -} -echo $schreiben ? "$gesamt Stelle(n) repariert.\n" : "$gesamt Stelle(n) gefunden. Mit --schreiben anwenden.\n"; diff --git a/src/Command/RepairEntitiesCommand.php b/src/Command/RepairEntitiesCommand.php new file mode 100644 index 0000000..59fe885 --- /dev/null +++ b/src/Command/RepairEntitiesCommand.php @@ -0,0 +1,152 @@ +addOption('schreiben', null, InputOption::VALUE_NONE, 'Die Korrekturen wirklich speichern. Ohne diese Option wird nur gezeigt, was sich ändern würde.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $schreiben = (bool) $input->getOption('schreiben'); + $gesamt = 0; + $zeilen = []; + + // Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf. + $aktive = []; + foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) { + foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) { + if (!empty($seite[$spalte])) { + $aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')'; + } + } + } + foreach ($aktive as $revision => $bezeichnung) { + $roh = $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]); + if (!is_string($roh)) { + continue; + } + $dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR); + $zahl = 0; + $beispiele = []; + $dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele); + if (0 === $zahl) { + continue; + } + $gesamt += $zahl; + $zeilen[] = [$bezeichnung, $zahl, implode(' · ', $beispiele)]; + if ($schreiben) { + // Geprüft wie beim Speichern: eine Korrektur darf kein ungültiges Dokument hinterlassen. + $this->validator->validate($dokument, writing: true); + $this->db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [$this->json($dokument), $revision]); + } + } + + foreach ($this->db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) { + $dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR); + $zahl = 0; + $beispiele = []; + $dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele); + if (0 === $zahl) { + continue; + } + $gesamt += $zahl; + $zeilen[] = ['Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', $zahl, implode(' · ', $beispiele)]; + if ($schreiben) { + // Ein Bereich traegt keine Ueberschrift - genau wie im PartialRepository. + $this->validator->validate($dokument, requireHeading: false, writing: true); + $this->db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [$this->json($dokument), $bereich['id']]); + } + } + + if (0 === $gesamt) { + $io->success('Nichts zu reparieren: kein Text trägt eine doppelt kodierte Entity.'); + + return Command::SUCCESS; + } + $io->table(['Wo', 'Stellen', 'Beispiel'], $zeilen); + if ($schreiben) { + $io->success(sprintf('%d Stelle(n) repariert.', $gesamt)); + + return Command::SUCCESS; + } + $io->warning(sprintf('%d Stelle(n) gefunden. Mit --schreiben anwenden.', $gesamt)); + + return Command::SUCCESS; + } + + /** @param array> $liste */ + private function bausteine(array $liste, int &$zahl, array &$beispiele): array + { + foreach ($liste as $index => $block) { + foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) { + $wert = $block['props'][$feld] ?? null; + if (!is_string($wert) || '' === $wert) { + continue; + } + $neu = $this->entschachteln($wert); + if ($neu !== $wert) { + $liste[$index]['props'][$feld] = $neu; + ++$zahl; + if (count($beispiele) < 3) { + $beispiele[] = mb_substr($wert, 0, 40).' → '.mb_substr($neu, 0, 40); + } + } + } + if (isset($block['children']) && is_array($block['children'])) { + $liste[$index]['children'] = $this->bausteine($block['children'], $zahl, $beispiele); + } + } + + return $liste; + } + + /** Entschachtelt so oft wie nötig: aus &amp;amp; wird &. Ein einzelnes & ist richtig und bleibt. */ + private function entschachteln(string $text): string + { + $vorher = null; + while ($vorher !== $text) { + $vorher = $text; + $text = preg_replace('/&(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text; + } + + return $text; + } + + /** @param array $dokument */ + private function json(array $dokument): string + { + return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/tests/RepairEntitiesCommandTest.php b/tests/RepairEntitiesCommandTest.php new file mode 100644 index 0000000..4f64230 --- /dev/null +++ b/tests/RepairEntitiesCommandTest.php @@ -0,0 +1,107 @@ +path = tempnam(sys_get_temp_dir(), 'kant-repair-'); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path)); + TestSchema::build($this->db); + $this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']); + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + @unlink($this->path); + } + + /** Legt eine Seite mit einem Text an und gibt die Kennung der Fassung zurück. */ + private function seite(string $text): string + { + $dokument = [ + 'schemaVersion' => 17, + 'title' => 'Start', + 'description' => 'Eine Seite zum Reparieren.', + 'blocks' => [['id' => 's1', 'type' => 'section', 'children' => [ + ['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Start', 'level' => 1]], + ['id' => 't1', 'type' => 'text', 'props' => ['text' => $text]], + ]]], + ]; + // Erst die Seite, dann ihre Fassung: die Fassung zeigt per Fremdschluessel auf die Seite. + $revision = bin2hex(random_bytes(16)); + $this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]); + $this->db->insert('revision', ['id' => $revision, 'page_id' => 'page-1', 'document' => json_encode($dokument, JSON_THROW_ON_ERROR), 'created_at' => gmdate('Y-m-d H:i:s')]); + $this->db->update('page', ['published_revision_id' => $revision, 'draft_revision_id' => $revision], ['id' => 'page-1']); + + return $revision; + } + + private function text(string $revision): string + { + $dokument = json_decode((string) $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true, 512, JSON_THROW_ON_ERROR); + + return $dokument['blocks'][0]['children'][1]['props']['text']; + } + + private function tester(): CommandTester + { + return new CommandTester(new RepairEntitiesCommand($this->db, new DocumentValidator())); + } + + public function testTheDryRunReportsWithoutChangingAnything(): void + { + $revision = $this->seite('Freudenstadt &amp; Umgebung'); + $tester = $this->tester(); + $tester->execute([]); + + self::assertStringContainsString('1 Stelle', $tester->getDisplay()); + self::assertSame('Freudenstadt &amp; Umgebung', $this->text($revision), 'Ohne --schreiben bleibt alles stehen.'); + } + + public function testWritingUnnestsEveryLevelAndLeavesASingleEntityAlone(): void + { + // Drei Stufen, wie sie auf einer Seite standen, die dreimal gespeichert wurde. + $revision = $this->seite('Projekte &amp;amp; Lösungen, „Zitat&quot;, und ein richtiges & dazu'); + $tester = $this->tester(); + $tester->execute(['--schreiben' => true]); + + self::assertStringContainsString('repariert', $tester->getDisplay()); + self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision)); + + // Ein zweiter Lauf findet nichts mehr - und macht aus dem richtigen & kein nacktes &. + $zweiter = $this->tester(); + $zweiter->execute(['--schreiben' => true]); + self::assertStringContainsString('Nichts zu reparieren', $zweiter->getDisplay()); + self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision)); + } + + public function testAnUntouchedInstallationSaysSoAndKeepsQuiet(): void + { + $revision = $this->seite('Alles gut & sauber'); + $tester = $this->tester(); + $tester->execute(['--schreiben' => true]); + + self::assertStringContainsString('Nichts zu reparieren', $tester->getDisplay()); + self::assertSame('Alles gut & sauber', $this->text($revision)); + } +}