Make the entity repair a console command, not a script

scripts/ is on the exclusion list of build-release.php, so an installation
that runs from the release archive - the usual case on shared hosting -
would never have seen scripts/repair-doubled-entities.php. The 1.20.1 notes
told everyone to run it, which was only true for a Git working copy.

cms:repair:entities does the same work and ships with the application: a dry
run by default, --schreiben to apply, each change validated the way saving
validates it (pages with the heading rule, areas without, as in
PartialRepository). The script is gone; there is one way to do this now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 09:05:11 +02:00
1 parent d08d6f648a
commit f4ec0d9877
5 files changed
+262 -130

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Command;
use App\Content\DocumentValidator;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Räumt Texte auf, die vor 1.20.1 bei jedem Speichern eine Kodierungsstufe dazubekommen haben.
*
* `RichText::sanitise()` hat Text ohne ein einziges Tag nur escaped, statt ihn vorher zu
* dekodieren. Aus „Freudenstadt &amp; Umgebung“ wurde deshalb `&amp;amp;`, dann `&amp;amp;amp;`
* – bis die Entity sichtbar auf der Seite stand. Der Fehler ist behoben; dieser Befehl bringt in
* Ordnung, was schon in der Datenbank liegt.
*
* Angefasst wird, was in Gebrauch ist: die veröffentlichte und die Entwurfsfassung jeder Seite
* und die Bereiche. Ältere Fassungen bleiben Historie – wer eine zurückholt, speichert sie
* danach einmal, und der reparierte Kern schreibt sie richtig.
*/
#[AsCommand(name: 'cms:repair:entities', description: 'Repariert doppelt kodierte Texte (&amp;amp;) aus Fassungen vor 1.20.1.')]
final class RepairEntitiesCommand extends Command
{
public function __construct(private Connection $db, private DocumentValidator $validator)
{
parent::__construct();
}
protected function configure(): void
{
$this->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<int, array<string, mixed>> $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;amp; wird &amp;. Ein einzelnes &amp; ist richtig und bleibt. */
private function entschachteln(string $text): string
{
$vorher = null;
while ($vorher !== $text) {
$vorher = $text;
$text = preg_replace('/&amp;(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text;
}
return $text;
}
/** @param array<string, mixed> $dokument */
private function json(array $dokument): string
{
return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}