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; 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 & Umgebung');
+ $tester = $this->tester();
+ $tester->execute([]);
+
+ self::assertStringContainsString('1 Stelle', $tester->getDisplay());
+ self::assertSame('Freudenstadt & 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; Lösungen, „Zitat", 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));
+ }
+}