Seiten verschieben, Papierkorb, Fassungen, Zeitplan (Arbeitspaket F)
Migration 20260912000900 ergänzt page.publish_at, page.deleted_at und revision.seq. Verschieben nimmt den ganzen Teilbaum mit und hinterlässt für jede alte Adresse ein 301. Kollisionen, Tiefengrenze und das Verschieben unter eigene Unterseiten werden vor der ersten Änderung abgewiesen; alles in einer Transaktion, weil ein halb verschobener Teilbaum Seiten unerreichbar machen würde. Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle: alle Fassungen bleiben, öffentlich 404, verschwunden aus Navigation, Archiven, Feed, Sitemap, llms.txt und Editorliste. Unterseiten gehen mit, beim Wiederherstellen muss das Elternteil zuerst zurück. Eine Fassung wiederherzustellen überschreibt nichts, sondern legt eine neue an. revision.seq behebt dabei einen echten Fehler: die Reihenfolge des Verlaufs war nicht eindeutig, weil created_at nur Sekunden kennt und mehrere Speichervorgänge in dieselbe Sekunde fallen - "vorherige Fassung wiederherstellen" hätte die falsche treffen können. Geplante Veröffentlichung über cms:publish-due. Ohne Cron bleibt die Seite ein Entwurf, was die sichere Richtung ist. Eine frei wählbare Startseite fehlt bewusst: der Pfad home gilt an elf Stellen als Wurzel, das ist ein eigener Schritt (ADR 022). Geprüft: 160 PHP-Tests / 622 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, Migration zuerst gegen eine Datenbankkopie samt Kontrolle des Backfills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba56add678
commit
6afad30956
16 files changed
+750
-17
No files matched your search
@@ -268,3 +268,19 @@ Die Verwaltung erlaubt zusätzlich die Schrifthosts, damit die Vorschau dieselbe
|
||||
Zwei weitere Fehler derselben Ursache wurden dabei gefunden: die Einrückung der Unterseiten in der Seitenliste lief über ein `style`-Attribut und wurde ebenfalls verworfen – die Hierarchie sah immer flach aus. Sie nutzt jetzt Klassen. Und das Theme setzte **alle** Links auf die Akzentfarbe und überschrieb damit die kontextabhängigen Farben; auf dunklem Grund ergab das 3,08:1. Die Regel gilt jetzt nur für Links in Textblöcken, und die Voreinstellung für Linkfarbe ist ein dunklerer Verwandter des Akzents, der auf allen hellen Tönen über 4,5:1 liegt.
|
||||
|
||||
Bewegungseffekte bleiben als eigener Abschnitt im Panel für jeden Block erhalten: Schatten-, Hover- und Einblendungsvorlagen sind Keyframes und lassen sich mit den freien Deklarationen nicht ausdrücken. Die alten Tokenwerte erscheinen dagegen nur noch dort, wo eine Seite sie schon trägt.
|
||||
|
||||
## ADR 022 – Seiten verwalten: Verschieben, Papierkorb, Fassungen, Zeitplan (12.09.2026)
|
||||
|
||||
Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `revision.seq`.
|
||||
|
||||
**Verschieben nimmt den ganzen Teilbaum mit und hinterlässt Weiterleitungen.** Der Vorgang läuft in einer Transaktion: ein halb verschobener Teilbaum hätte Seiten unerreichbar gemacht. Vor der ersten Änderung werden alle betroffenen Adressen eingesammelt, auf Kollisionen und auf die Tiefengrenze geprüft; erst danach wird geschrieben und für jede alte Adresse ein 301 vermerkt. Eine Seite unter eine ihrer eigenen Unterseiten zu verschieben wird abgewiesen, weil das den Teilbaum abtrennen würde.
|
||||
|
||||
**Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle.** Eine Seite im Papierkorb behält alle Fassungen, antwortet öffentlich mit 404 und verschwindet aus Navigation, Archiven, Feed, Sitemap, `llms.txt` und der Editorliste. Unterseiten gehen mit, weil sie ohne ihr Elternteil in der Adresse nicht erreichbar wären; beim Wiederherstellen muss deshalb zuerst das Elternteil zurück. Das endgültige Löschen mit allen Fassungen bleibt der Administratorrolle.
|
||||
|
||||
**Eine Fassung wiederherzustellen überschreibt nichts.** Das alte Dokument wird erneut gespeichert und damit eine neue Fassung, sodass der bisherige Stand in der Liste bleibt.
|
||||
|
||||
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
|
||||
|
||||
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
|
||||
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
|
||||
+3
-1
@@ -104,10 +104,12 @@ SEO-Felder je Seite: Titel, Beschreibung, Indexierungssteuerung, Open-Graph-Bild
|
||||
|
||||
Abnahme: Sitemap und robots.txt stimmen mit dem veröffentlichten Bestand überein. Eine Adressänderung erzeugt eine Weiterleitung. Strukturierte Daten decken sich mit sichtbarem Inhalt und bestehen die Prüfung mit dem Testwerkzeug.
|
||||
|
||||
### F – Seiten verwalten
|
||||
### F – Seiten verwalten — **erledigt am 12.09.2026, ohne freie Startseite**
|
||||
|
||||
*Braucht: A und E für Menüs und Weiterleitungen.*
|
||||
|
||||
Umgesetzt: Umbenennen und Verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, geplante Veröffentlichung, Fassungsverlauf mit Wiederherstellen und ein Papierkorb statt sofortigem Löschen. **Offen geblieben:** eine frei wählbare Startseite – der Pfad `home` gilt an elf Stellen als Wurzel, das ist ein eigener Schritt (ADR 022). Ebenso kein automatisches Leeren des Papierkorbs und kein Vergleich zweier Fassungen.
|
||||
|
||||
Seiten umbenennen und verschieben, mit Aktualisierung aller Kindpfade in einer Transaktion und automatischer Weiterleitung von der alten Adresse. Startseite frei wählbar. Geplantes Veröffentlichen. Revisionsliste mit Vergleich und Wiederherstellung in der Oberfläche. Papierkorb statt sofortigem Löschen.
|
||||
|
||||
Abnahme: Eine Seite mit Unterseiten wird verschoben; alle alten Adressen leiten weiter, Menüs bleiben intakt.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Seiten verwalten, 12.09.2026
|
||||
|
||||
- **160 PHP-Tests / 622 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests** und der vollständige Playwright-Editortest mit axe (null Verstöße).
|
||||
- Verschieben geprüft: eine Seite mit Kind und Enkel wird umbenannt, alle drei Adressen ändern sich, jede neue antwortet mit 200, jede alte mit 301 auf genau ihr neues Ziel, und Slug sowie Elternbeziehung der Nachkommen bleiben unverändert.
|
||||
- Vier abgewiesene Verschiebungen bewegen nichts und erzeugen keine Weiterleitung: unter die eigene Unterseite, in sich selbst, auf eine vergebene Adresse und mit einem unbrauchbaren Slug. Die Startseite wird ebenfalls abgewiesen.
|
||||
- Papierkorb geprüft: der Teilbaum geht mit, die Seiten antworten öffentlich mit 404 und verschwinden aus Navigation, Sitemap, Beitragsarchiv, Feed und Editorliste. Ein Kind lässt sich nicht vor seinem Elternteil wiederherstellen, und das Wiederherstellen einer Seite holt keine andere zurück.
|
||||
- Fassungen geprüft: Reihenfolge neueste zuerst, Wiederherstellen erzeugt eine zusätzliche Fassung statt zu überschreiben, eine Fassung einer anderen Seite wird abgewiesen.
|
||||
- Zeitplan geprüft: ein künftiger Zeitpunkt veröffentlicht nichts, ein vergangener genau einmal, danach ist der Zeitpunkt verbraucht, ein ungültiges Format wird abgewiesen, und eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
- **Ein echter Fehler kam dabei heraus:** die Reihenfolge des Fassungsverlaufs war nicht eindeutig. `created_at` hat Sekundengenauigkeit, mehrere Speichervorgänge fallen in dieselbe Sekunde, und die Liste richtete sich dann nach der zufälligen Revisions-ID – „vorherige Fassung wiederherstellen“ hätte die falsche treffen können. Jede Fassung trägt jetzt einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln und wurde vorher gegen eine Kopie der Datenbank geprüft.
|
||||
- Migration `20260912000900` erst gegen eine Kopie ausgeführt und der Backfill kontrolliert (drei Fassungen von `/grundlagen` erhielten seq 1 bis 3 in der richtigen Reihenfolge), danach nach Backup angewendet. `cms:publish-due` ausgeführt.
|
||||
|
||||
## Ergänzung: Browsertests wiederhergestellt, 12.09.2026
|
||||
|
||||
- **155 PHP-Tests / 579 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests** und erstmals seit dem Editorumbau wieder der **vollständige Playwright-Editortest**.
|
||||
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,11 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Seiten umbenennen und verschieben** zieht den ganzen Teilbaum mit und hinterlässt für jede alte Adresse eine dauerhafte Weiterleitung. Kollisionen, Tiefengrenze und das Verschieben unter eigene Unterseiten werden vorher abgewiesen; alles in einer Transaktion.
|
||||
- **Papierkorb statt sofortigem Löschen** für die Editorrolle: alle Fassungen bleiben, öffentlich 404, verschwunden aus Navigation, Archiven, Feed, Sitemap, `llms.txt` und Editorliste. Unterseiten gehen mit; beim Wiederherstellen muss das Elternteil zuerst zurück. Endgültiges Löschen bleibt der Administratorrolle.
|
||||
- **Fassungsverlauf je Seite** unter `/admin/pages/{id}/revisions` mit Wiederherstellen. Das überschreibt nichts: die alte Fassung wird als neuer Entwurf gespeichert.
|
||||
- **`revision.seq` behebt einen echten Fehler:** die Reihenfolge des Verlaufs war nicht eindeutig, weil `created_at` nur Sekunden kennt und mehrere Speichervorgänge in dieselbe Sekunde fallen. Jede Fassung hat jetzt einen je Seite aufsteigenden Zähler.
|
||||
- **Geplante Veröffentlichung** über `publish_at` und den Cron-Befehl `cms:publish-due`. Ohne Cron bleibt die Seite ein Entwurf. Zeiten in UTC; Seiten im Papierkorb werden nie durch den Zeitplan veröffentlicht.
|
||||
- **Die erzeugten Stile wirkten in der Editorvorschau nicht.** Ein `srcdoc`-Iframe erbt die Richtlinie der Verwaltung, die Inline-Styles verbietet; das Stylesheet wurde stillschweigend verworfen. Die Vorschau liefert es jetzt als inertes `<template>`, der Editor wendet es über `adoptedStyleSheets` an. Ausgeliefert wurde es die ganze Zeit korrekt – nur die Vorschau log.
|
||||
- **Die Einrückung der Unterseiten in der Seitenliste hat nie funktioniert**: sie lief über ein `style`-Attribut, das dieselbe Richtlinie verwirft. Jetzt über Klassen.
|
||||
- **Das Theme überschrieb alle Linkfarben** und erzeugte damit 3,08:1 auf dunklem Grund. Die Regel gilt jetzt nur für Links in Textblöcken; die voreingestellte Linkfarbe `#a4510f` liegt auf allen hellen Tönen über 4,5:1.
|
||||
@@ -48,8 +53,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
## Geprüft
|
||||
|
||||
- 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests und der vollständige Playwright-Editortest. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- 160 PHP-Tests / 622 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests und der vollständige Playwright-Editortest. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Seitenverwaltung geprüft: Verschieben ändert drei Adressen über zwei Ebenen, jede neue antwortet mit 200 und jede alte mit 301 auf das richtige Ziel, Slugs und Elternbeziehungen der Unterseiten bleiben erhalten. Vier Fälle werden abgewiesen, ohne etwas zu bewegen oder eine Weiterleitung zu erfinden: in die eigene Unterseite, in sich selbst, auf eine vergebene Adresse und mit einem unbrauchbaren Slug; dazu die Startseite. Papierkorb geprüft: Teilbaum geht mit, öffentlich 404, verschwunden aus Navigation, Sitemap, Archiv, Feed und Editorliste, Kind kann nicht vor dem Elternteil zurück, Wiederherstellen stellt nur die gewählte Seite zurück. Fassungen geprüft: Reihenfolge neueste zuerst, Wiederherstellen fügt eine Fassung hinzu statt zu überschreiben, eine fremde Fassung wird abgewiesen. Zeitplan geprüft: ein künftiger Zeitpunkt veröffentlicht nichts, ein vergangener genau einmal, danach ist der Zeitpunkt verbraucht, und eine Seite im Papierkorb wird nie veröffentlicht.
|
||||
- **Der vollständige Browser-End-to-End-Test läuft wieder und deckt jetzt das neue System ab.** `npm run test:editor` war durch den Umbau kaputt (er steuerte die abgeschafften Auswahllisten) und wurde auf die freien Bedienelemente umgeschrieben: Einheitenfelder, Vererbung über drei Geräte, einzelnes Zurücksetzen, Palettenfarbe, Abstand je Seite, Hover-Regel über den Template-Weg, freie Rasterspalten und die weiterhin vorhandenen Bewegungseffekte. **axe meldet null Verstöße** auf Login, Seitenliste, Editor in Desktop- und Mobilbreite, Medienauswahl und der bearbeiteten veröffentlichten Seite.
|
||||
- **Lighthouse 13.4.1 auf der Startseite mit dem neuen Theme-System: je drei Läufe mobil und Desktop, alle fünf Kategorien 100.** Das ist die erste Messung seit dem Umbau des Editors.
|
||||
- **Im laufenden Editor durchgespielt:** Doppelklick setzt das Element auf `contenteditable="plaintext-only"`, eine Eingabe landet im Modell (Speicherstatus wechselt auf „Ungespeicherte Änderungen“, der Strukturbaum zeigt den neuen Text), und das Verlassen beendet die Bearbeitung sauber. Die Kontrastwarnung meldete bei `#cccccc` auf `#ffffff` korrekt „Kontrast 1.6:1 – zu wenig“. Die Schaltflächen zum Kopieren und Einfügen erscheinen mit richtigem Aktivierungszustand. Die Teständerungen wurden verworfen und nicht gespeichert.
|
||||
@@ -86,6 +92,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **`npm run test:browser` schlägt derzeit fehl** – nicht wegen des Codes, sondern wegen eines Inhalts: die Seite `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund, 2,75:1, unter der AA-Schwelle. Die Kontrastwarnung im Editor meldet genau das. Die Farbe wurde bewusst **nicht** verändert, weil sie eine Nutzerentscheidung ist.
|
||||
- **Eine frei wählbare Startseite fehlt.** Der Pfad `home` gilt an elf Stellen als Wurzel; das zu einer Einstellung zu machen ist ein eigener Schritt. Bis dahin behält die Startseite ihre Adresse. Ebenfalls offen: der Papierkorb wird nicht automatisch geleert, es gibt keine Aufbewahrungsfrist, und Weiterleitungen aus einem Verschiebevorgang lassen sich nur einzeln in den Einstellungen entfernen.
|
||||
- Weiterhin keine Erprobung mit einem Screenreader. Bei der Direktbearbeitung wiegt das: dass der Fokus sinnvoll wandert und der Zustandswechsel angekündigt wird, ist durch axe nicht abgedeckt.
|
||||
- Gestaltungsvorlagen lassen sich in der Oberfläche nur anlegen und anwenden, nicht umbenennen; das Entfernen geht bisher nur über die Schnittstelle. Es gibt keine Vorschau einer Vorlage.
|
||||
- SEO-Titel und -Beschreibung sind weiterhin die Dokumentfelder; getrennte SEO-Titel gibt es nicht. Das Vorschaubild wird über seine Mediathek-ID eingetragen und noch nicht als `og:image` aufgelöst. Weiterleitungen werden nur bei genauer Übereinstimmung des Pfades gefunden, ohne Muster und ohne Berücksichtigung von Abfrageparametern; Seiten umbenennen erzeugt noch keine automatische Weiterleitung (Paket F).
|
||||
@@ -104,7 +111,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Die Textfarbe auf `/grundlagen` mit Umut klären, damit die öffentliche Prüfung wieder grün ist. Danach den Editor mit einem Screenreader prüfen, Gestaltungsvorlagen in der Oberfläche verwalten und die Direktbearbeitung auf weitere Textstellen ausdehnen. Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
Die Textfarbe auf `/grundlagen` mit Umut klären, damit die öffentliche Prüfung wieder grün ist. Danach die frei wählbare Startseite, den Editor mit einem Screenreader prüfen und Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Index;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912000900 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Scheduled publication and a recycle bin instead of immediate deletion.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$page = $schema->getTable('page');
|
||||
// When set and in the past, the stored draft is published by cms:publish-due.
|
||||
$page->addColumn('publish_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// A page in the bin keeps every revision but answers publicly with 404 and stays out of
|
||||
// menus, archives and the sitemap until it is restored or finally removed.
|
||||
$page->addColumn('deleted_at', 'datetime_immutable', ['notnull' => false]);
|
||||
|
||||
// created_at only has second precision, and several saves land in the same second. The
|
||||
// revision list and "restore this version" need a strict order, so every revision of a
|
||||
// page carries a counter. Filled in postUp() from the existing timestamps.
|
||||
$schema->getTable('revision')->addColumn('seq', 'integer', ['notnull' => true, 'default' => 0]);
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
// Existing revisions keep their order; within one second the previous order is preserved.
|
||||
foreach ($this->connection->fetchAllAssociative('SELECT DISTINCT page_id FROM revision') as $page) {
|
||||
$number = 0;
|
||||
foreach ($this->connection->fetchAllAssociative(
|
||||
'SELECT id FROM revision WHERE page_id = ? ORDER BY created_at ASC, id ASC',
|
||||
[$page['page_id']],
|
||||
) as $revision) {
|
||||
$this->connection->executeStatement('UPDATE revision SET seq = ? WHERE id = ?', [++$number, $revision['id']]);
|
||||
}
|
||||
}
|
||||
self::applyIndexes($this->connection);
|
||||
}
|
||||
|
||||
/** Shared with the test bootstraps so they get the same lookup indexes as a migrated database. */
|
||||
public static function applyIndexes(Connection $connection): void
|
||||
{
|
||||
$manager = $connection->createSchemaManager();
|
||||
$existing = $manager->listTableIndexes('page');
|
||||
foreach ([
|
||||
new Index('idx_page_publish_at', ['publish_at']),
|
||||
new Index('idx_page_deleted_at', ['deleted_at']),
|
||||
] as $index) {
|
||||
if (!isset($existing[$index->getName()])) {
|
||||
$connection->executeStatement($connection->getDatabasePlatform()->getCreateIndexSQL($index, 'page'));
|
||||
}
|
||||
}
|
||||
if (!isset($manager->listTableIndexes('revision')['idx_revision_order'])) {
|
||||
$connection->executeStatement($connection->getDatabasePlatform()->getCreateIndexSQL(
|
||||
new Index('idx_revision_order', ['page_id', 'seq']),
|
||||
'revision',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Dropping deleted_at would silently republish pages an editor removed. Restore a verified database backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Publishes pages whose scheduled moment has passed. Meant for a cron entry, for example every
|
||||
* five minutes. Without it a scheduled page simply stays a draft, which is the safe direction.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:publish-due', description: 'Publish pages whose scheduled publication time has passed.')]
|
||||
final class PublishDueCommand extends Command
|
||||
{
|
||||
public function __construct(private PageRepository $pages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$published = $this->pages->publishDue();
|
||||
foreach ($published as $path) {
|
||||
$output->writeln('Published: /'.$path);
|
||||
}
|
||||
$output->writeln(sprintf('%d Seite(n) veröffentlicht.', count($published)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ final class MenuRepository
|
||||
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
|
||||
{
|
||||
$pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ?', ['default', $locale]) as $page) {
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
|
||||
$pages[$page['id']] = $page;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,221 @@ final class PageRepository
|
||||
'id' => $revisionId, 'page_id' => $pageId,
|
||||
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
'seq' => 1,
|
||||
]);
|
||||
|
||||
return $pageId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames or moves a page and carries its whole subtree along.
|
||||
*
|
||||
* Every affected address gets a 301 from its old form, so a migrated or long lived site keeps
|
||||
* every external link working. All of it happens in one transaction: a half moved subtree
|
||||
* would leave pages unreachable.
|
||||
*
|
||||
* @return int the number of pages whose address changed
|
||||
*/
|
||||
public function movePage(string $pageId, string $slug, ?string $parentId, int $expectedVersion, RedirectRepository $redirects): int
|
||||
{
|
||||
$slug = strtolower(trim($slug));
|
||||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
||||
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
||||
}
|
||||
|
||||
return $this->db->transactional(function () use ($pageId, $slug, $parentId, $expectedVersion, $redirects): int {
|
||||
$page = $this->db->fetchAssociative('SELECT id, path, slug, parent_id, type, locale, version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
|
||||
if (false === $page) {
|
||||
throw new InvalidPage('Diese Seite gibt es nicht mehr.');
|
||||
}
|
||||
if ((int) $page['version'] !== $expectedVersion) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
if ('home' === $page['path']) {
|
||||
// The path 'home' is assumed as the site root in several places (public routing, canonical,
|
||||
// sitemap, llms.txt, menu resolution). Making it configurable is its own step; until
|
||||
// then the start page keeps its address rather than half of the system following it.
|
||||
throw new InvalidPage('Die Startseite behält ihre Adresse. Eine andere Seite als Startseite zu wählen ist noch nicht möglich.');
|
||||
}
|
||||
|
||||
$newPath = $slug;
|
||||
if (null !== $parentId) {
|
||||
if ($parentId === $pageId) {
|
||||
throw new InvalidPage('Eine Seite kann nicht ihre eigene übergeordnete Seite sein.');
|
||||
}
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $page['locale']]);
|
||||
if (false === $parent) {
|
||||
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
||||
}
|
||||
if ('page' !== $parent['type']) {
|
||||
throw new InvalidPage('Beiträge können keine Unterseiten enthalten.');
|
||||
}
|
||||
// Moving a page under one of its own descendants would detach the whole subtree.
|
||||
if ($parent['path'] === $page['path'] || str_starts_with($parent['path'].'/', $page['path'].'/')) {
|
||||
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
|
||||
}
|
||||
$newPath = $parent['path'].'/'.$slug;
|
||||
}
|
||||
if ($newPath === $page['path']) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Collect the subtree before anything changes, so the old addresses are still known.
|
||||
$affected = $this->db->fetchAllAssociative(
|
||||
'SELECT id, path FROM page WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) ORDER BY path',
|
||||
['default', $page['locale'], $page['path'], $page['path'].'/%'],
|
||||
);
|
||||
$depth = substr_count($newPath, '/') + 1;
|
||||
foreach ($affected as $row) {
|
||||
$moved = $newPath.substr($row['path'], strlen($page['path']));
|
||||
if (substr_count($moved, '/') + 1 > self::MAX_DEPTH) {
|
||||
throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen; die Unterseiten würden zu tief liegen.', self::MAX_DEPTH));
|
||||
}
|
||||
$taken = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ? AND id <> ?', ['default', $page['locale'], $moved, $row['id']]);
|
||||
if (false !== $taken) {
|
||||
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $moved));
|
||||
}
|
||||
}
|
||||
unset($depth);
|
||||
|
||||
foreach ($affected as $row) {
|
||||
$moved = $newPath.substr($row['path'], strlen($page['path']));
|
||||
$this->db->executeStatement('UPDATE page SET path = ? WHERE id = ?', [$moved, $row['id']]);
|
||||
$redirects->remember($row['path'], $moved);
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
|
||||
|
||||
return count($affected);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page into the bin. Its revisions stay; publicly it answers 404 and it disappears
|
||||
* from menus, archives and the sitemap. Subpages go along, because leaving them behind would
|
||||
* make them unreachable without their parent in the address.
|
||||
*
|
||||
* @return int the number of pages moved into the bin
|
||||
*/
|
||||
public function trashPage(string $pageId, int $expectedVersion): int
|
||||
{
|
||||
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
|
||||
$page = $this->db->fetchAssociative('SELECT path, locale, version, deleted_at FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
|
||||
if (false === $page || null !== $page['deleted_at']) {
|
||||
throw new InvalidPage('Diese Seite ist nicht mehr vorhanden.');
|
||||
}
|
||||
if ((int) $page['version'] !== $expectedVersion) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
if ('home' === $page['path']) {
|
||||
throw new InvalidPage('Die Startseite kann nicht in den Papierkorb.');
|
||||
}
|
||||
|
||||
return $this->db->executeStatement(
|
||||
'UPDATE page SET deleted_at = ?, version = version + 1 WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL',
|
||||
[gmdate('Y-m-d H:i:s'), 'default', $page['locale'], $page['path'], $page['path'].'/%'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Takes a page out of the bin. Its ancestors have to be present, or the address would break. */
|
||||
public function restorePage(string $pageId): void
|
||||
{
|
||||
$this->db->transactional(function () use ($pageId): void {
|
||||
$page = $this->db->fetchAssociative('SELECT path, parent_id, locale FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NOT NULL', [$pageId, 'default']);
|
||||
if (false === $page) {
|
||||
throw new InvalidPage('Diese Seite liegt nicht im Papierkorb.');
|
||||
}
|
||||
if (null !== $page['parent_id']) {
|
||||
$parent = $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND deleted_at IS NULL', [$page['parent_id']]);
|
||||
if (false === $parent) {
|
||||
throw new InvalidPage('Stelle zuerst die übergeordnete Seite wieder her.');
|
||||
}
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET deleted_at = NULL, version = version + 1 WHERE id = ?', [$pageId]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, path: string, deletedAt: string}> */
|
||||
public function trash(string $locale = 'de'): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => ['id' => $row['id'], 'path' => $row['path'], 'deletedAt' => $row['deleted_at']], $this->db->fetchAllAssociative(
|
||||
'SELECT id, path, deleted_at FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
|
||||
['default', $locale],
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, createdAt: string, title: string, published: bool, draft: bool}> */
|
||||
public function revisions(string $pageId, int $limit = 30): array
|
||||
{
|
||||
$page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]);
|
||||
|
||||
return array_map(static function (array $row) use ($page): array {
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'createdAt' => $row['created_at'],
|
||||
'title' => $document['title'] ?? '',
|
||||
'published' => $row['id'] === ($page['published_revision_id'] ?? null),
|
||||
'draft' => $row['id'] === ($page['draft_revision_id'] ?? null),
|
||||
];
|
||||
}, $this->db->fetchAllAssociative(
|
||||
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.max(1, min($limit, 100)),
|
||||
[$pageId],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an earlier revision the current draft by storing its document again. Nothing is
|
||||
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
|
||||
*/
|
||||
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||
if (false === $raw) {
|
||||
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
|
||||
}
|
||||
|
||||
return $this->saveDraft($pageId, json_decode($raw, true, 512, JSON_THROW_ON_ERROR), $expectedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes every page whose scheduled moment has passed. Run from cron; without it a
|
||||
* scheduled page simply stays a draft, which is the safe direction.
|
||||
*
|
||||
* @return list<string> the paths that went public
|
||||
*/
|
||||
public function publishDue(): array
|
||||
{
|
||||
$published = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
|
||||
['default', gmdate('Y-m-d H:i:s')],
|
||||
) as $row) {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
||||
$published[] = $row['path'];
|
||||
}
|
||||
|
||||
return $published;
|
||||
}
|
||||
|
||||
public function schedule(string $pageId, string $when, int $expectedVersion): void
|
||||
{
|
||||
$when = trim($when);
|
||||
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
|
||||
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
|
||||
}
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a page and its revisions. Pages with children stay untouched so no content
|
||||
* disappears silently; the editor has to deal with the children first.
|
||||
@@ -111,6 +320,8 @@ final class PageRepository
|
||||
'id' => $id, 'page_id' => $pageId,
|
||||
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
// Strictly increasing per page, because several saves share one second.
|
||||
'seq' => 1 + (int) $this->db->fetchOne('SELECT COALESCE(MAX(seq), 0) FROM revision WHERE page_id = ?', [$pageId]),
|
||||
]);
|
||||
|
||||
return $id;
|
||||
@@ -143,7 +354,7 @@ final class PageRepository
|
||||
public function findPublished(string $path, string $locale = 'de'): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
@@ -157,12 +368,12 @@ final class PageRepository
|
||||
|
||||
public function listForEditor(): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
|
||||
}
|
||||
|
||||
public function findForEditor(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ?', [$id, 'default']);
|
||||
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']);
|
||||
if (false === $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ final class PostRepository
|
||||
*/
|
||||
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
|
||||
{
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL";
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
||||
$params = ['default', $locale];
|
||||
$join = '';
|
||||
if (null !== $termId) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
@@ -23,7 +24,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->render('admin/pages.html.twig', [
|
||||
'pages' => $pages,
|
||||
'trash' => $this->pages->trash(),
|
||||
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
|
||||
]);
|
||||
}
|
||||
@@ -144,10 +146,113 @@ final class EditorController extends AbstractController
|
||||
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])]
|
||||
public function move(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$moved = $this->pages->movePage(
|
||||
$id,
|
||||
$request->request->getString('slug'),
|
||||
$request->request->getString('parent_id') ?: null,
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
$this->redirects,
|
||||
);
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back(0 === $moved
|
||||
? 'Die Adresse ist unverändert.'
|
||||
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
|
||||
public function schedule(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->pages->schedule($id, $request->request->getString('publishAt'), $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
public function restorePage(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->pages->restorePage($id);
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function restoreRevision(Request $request, string $id, string $revision): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Fassung ist als Entwurf zurück. Der bisherige Stand bleibt als eigene Fassung erhalten.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
|
||||
public function revisions(string $id): Response
|
||||
{
|
||||
$page = $this->page($id);
|
||||
|
||||
return $this->render('admin/revisions.html.twig', [
|
||||
'page' => $page,
|
||||
'title' => $page['document']['title'] ?? $page['path'],
|
||||
'revisions' => $this->pages->revisions($id),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/trash', name: 'admin_trash', methods: ['POST'])]
|
||||
public function trash(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back(sprintf('%d Seite(n) in den Papierkorb gelegt. Öffentlich nicht mehr erreichbar, aber wiederherstellbar.', $count), 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
// Deleting removes every revision of a page, so it stays with the administrator role.
|
||||
// The final removal deletes every revision, so it stays with the administrator role.
|
||||
// Everyone with the editor role uses the bin instead.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
@@ -32,7 +32,7 @@ final class SeoController extends AbstractController
|
||||
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
||||
// sitemap would ask a search engine to fetch something it must not list.
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' ORDER BY p.path",
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
|
||||
['default'],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
@@ -45,7 +45,7 @@ final class SeoController extends AbstractController
|
||||
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
|
||||
}
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL ORDER BY t.kind, t.slug',
|
||||
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL ORDER BY t.kind, t.slug',
|
||||
) as $row) {
|
||||
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
|
||||
}
|
||||
@@ -88,7 +88,7 @@ final class SeoController extends AbstractController
|
||||
}
|
||||
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.type = ? ORDER BY p.path",
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
|
||||
['default', $type],
|
||||
);
|
||||
if ([] === $rows) {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="page-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</div>
|
||||
<div class="page-info">
|
||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} <span>· {{ page.locale|upper }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }}</span></p>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} <span>· {{ page.locale|upper }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</span></p>
|
||||
</div>
|
||||
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span>
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||
@@ -99,13 +99,58 @@
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<details class="meta-panel">
|
||||
<summary aria-label="Adresse von {{ page.title }} ändern">Adresse</summary>
|
||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||
<label for="parent-{{ page.id }}">Übergeordnete Seite</label>
|
||||
<select id="parent-{{ page.id }}" name="parent_id">
|
||||
<option value="">— keine, direkt unter der Startseite —</option>
|
||||
{% for parent in parents|filter(candidate => candidate.id != page.id) %}
|
||||
<option value="{{ parent.id }}"{{ page.parent_id == parent.id ? ' selected' : '' }}>{{ parent.title }} (/{{ parent.path }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||
<button class="primary" type="submit">Adresse ändern</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<details class="meta-panel">
|
||||
<summary aria-label="Veröffentlichung von {{ page.title }} planen">{{ page.publish_at ? 'Geplant' : 'Planen' }}</summary>
|
||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
|
||||
<button class="primary" type="submit">Zeitpunkt speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
<a class="secondary" href="{{ path('admin_revisions', {id: page.id}) }}">Fassungen</a>
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<details class="delete-panel">
|
||||
<summary aria-label="{{ page.title }} in den Papierkorb legen">Papierkorb</summary>
|
||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<p>Legt „{{ page.title }}“ samt Unterseiten in den Papierkorb. Öffentlich nicht mehr erreichbar, aber jederzeit wiederherstellbar.</p>
|
||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<details class="delete-panel">
|
||||
<summary aria-label="{{ page.title }} löschen">Löschen</summary>
|
||||
<summary aria-label="{{ page.title }} endgültig löschen">Endgültig</summary>
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<p>Löscht „{{ page.title }}“ mit allen gespeicherten Ständen. Das lässt sich nicht rückgängig machen.</p>
|
||||
<p>Löscht „{{ page.title }}“ mit allen gespeicherten Fassungen, ohne Papierkorb. Das lässt sich nicht rückgängig machen.</p>
|
||||
<button class="secondary danger-text" type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -115,6 +160,26 @@
|
||||
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if trash is not empty %}
|
||||
<div class="section-heading"><h2>Papierkorb <span class="count">{{ trash|length }}</span></h2><span class="muted">Nicht öffentlich erreichbar, alle Fassungen erhalten.</span></div>
|
||||
<div class="page-list">
|
||||
{% for entry in trash %}
|
||||
<article class="page-card">
|
||||
<div class="page-icon" aria-hidden="true">🗑</div>
|
||||
<div class="page-info">
|
||||
<h3>/{{ entry.path }}</h3>
|
||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
{% block title %}Fassungen von {{ title }}{% endblock %}
|
||||
{% block body %}
|
||||
<header class="admin-header">
|
||||
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
||||
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
|
||||
</header>
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">FASSUNGEN</p><h1>{{ title }}</h1>
|
||||
<p class="muted">Jedes Speichern erzeugt eine eigene Fassung. Eine frühere Fassung wiederherzustellen überschreibt nichts: sie wird als neuer Entwurf gespeichert, der bisherige Stand bleibt in der Liste.</p>
|
||||
<p class="admin-links">
|
||||
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}">Im Editor öffnen</a>
|
||||
</p>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
{% endfor %}{% endfor %}
|
||||
|
||||
<div class="section-heading"><h2>Verlauf <span class="count">{{ revisions|length }}</span></h2><span class="muted">Neueste zuerst, höchstens 30.</span></div>
|
||||
<div class="page-list">
|
||||
{% for revision in revisions %}
|
||||
<article class="page-card">
|
||||
<div class="page-icon" aria-hidden="true">⟲</div>
|
||||
<div class="page-info">
|
||||
<h3>{{ revision.title }}</h3>
|
||||
<p><time datetime="{{ revision.createdAt|date('c') }}">{{ revision.createdAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if revision.published %}<span class="badge published">Veröffentlicht</span>{% endif %}
|
||||
{% if revision.draft %}<span class="badge">Aktueller Entwurf</span>{% endif %}
|
||||
{% if not revision.draft %}
|
||||
<form action="{{ path('admin_restore_revision', {id: page.id, revision: revision.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="secondary" type="submit">Als Entwurf wiederherstellen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty-state"><h3>Noch keine Fassungen.</h3></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -26,6 +26,7 @@ use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -55,11 +56,13 @@ final class CmsTest extends TestCase
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($this->db);
|
||||
Version20260912000600::applyIndexes($this->db);
|
||||
Version20260912000900::applyIndexes($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
@@ -1209,6 +1212,158 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
|
||||
}
|
||||
|
||||
public function testMovingAPageCarriesItsSubtreeAndLeavesRedirectsBehind(): void
|
||||
{
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$grandchild = $this->publishedChild('shop', 'Shop', $child);
|
||||
foreach (['leistungen', 'leistungen/webdesign', 'leistungen/webdesign/shop'] as $path) {
|
||||
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
|
||||
}
|
||||
|
||||
$moved = $this->pages->movePage($parent, 'services', null, $this->pages->findForEditor($parent)['version'], $this->redirects);
|
||||
self::assertSame(3, $moved, 'The page and both descendants change address.');
|
||||
|
||||
// Every new address answers and every old one redirects permanently.
|
||||
foreach ([
|
||||
'services' => 'leistungen',
|
||||
'services/webdesign' => 'leistungen/webdesign',
|
||||
'services/webdesign/shop' => 'leistungen/webdesign/shop',
|
||||
] as $now => $before) {
|
||||
self::assertSame(200, $this->request('/'.$now)->getStatusCode(), $now);
|
||||
$old = $this->request('/'.$before);
|
||||
self::assertSame(301, $old->getStatusCode(), $before);
|
||||
self::assertSame('/'.$now, $old->headers->get('Location'), $before);
|
||||
}
|
||||
// The grandchild kept its own slug and its parent link.
|
||||
self::assertSame('shop', $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$grandchild]));
|
||||
self::assertSame($child, $this->db->fetchOne('SELECT parent_id FROM page WHERE id = ?', [$grandchild]));
|
||||
}
|
||||
|
||||
public function testMoveRefusesCyclesCollisionsAndTheHomePage(): void
|
||||
{
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$other = $this->publishedPage('beratung', 'Beratung');
|
||||
|
||||
$cases = [
|
||||
'into its own child' => [$parent, 'leistungen', $child],
|
||||
'into itself' => [$parent, 'leistungen', $parent],
|
||||
'onto a taken address' => [$other, 'leistungen', null],
|
||||
'with a slug that is no slug' => [$other, 'mit leerzeichen', null],
|
||||
];
|
||||
foreach ($cases as $name => [$page, $slug, $target]) {
|
||||
try {
|
||||
$this->pages->movePage($page, $slug, $target, $this->pages->findForEditor($page)['version'], $this->redirects);
|
||||
self::fail(sprintf('Expected "%s" to be refused.', $name));
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
// The home page keeps its address; a different start page is a settings matter.
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
try {
|
||||
$this->pages->movePage('page-1', 'start', null, $this->pages->findForEditor('page-1')['version'], $this->redirects);
|
||||
self::fail('Expected the home page to be refused.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
// Nothing moved and no redirect was invented.
|
||||
self::assertSame(['beratung', 'home', 'leistungen', 'leistungen/webdesign', 'private'], $this->db->fetchFirstColumn('SELECT path FROM page ORDER BY path'));
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
|
||||
}
|
||||
|
||||
public function testTrashHidesAPageEverywhereAndRestoreBringsItBack(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $parent, 'anchor' => '', 'children' => []],
|
||||
], 0);
|
||||
self::assertStringContainsString('Leistungen', $this->request('/')->getContent());
|
||||
|
||||
// Trashing takes the subtree along.
|
||||
self::assertSame(2, $this->pages->trashPage($parent, $this->pages->findForEditor($parent)['version']));
|
||||
self::assertSame(404, $this->request('/leistungen')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/leistungen/webdesign')->getStatusCode());
|
||||
self::assertStringNotContainsString('Leistungen', $this->request('/')->getContent(), 'A trashed page leaves the navigation.');
|
||||
self::assertStringNotContainsString('/leistungen', $this->request('/sitemap.xml')->getContent());
|
||||
self::assertNull($this->pages->findForEditor($parent), 'A trashed page is out of the editor listing.');
|
||||
self::assertSame([$parent, $child], array_column($this->pages->trash(), 'id'));
|
||||
|
||||
// A trashed post disappears from the archive and the feed as well.
|
||||
$this->pages->trashPage($post, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$post]));
|
||||
self::assertSame(0, $this->posts->published()['total']);
|
||||
self::assertStringNotContainsString('Ein Beitrag', $this->request('/feed.xml')->getContent());
|
||||
|
||||
// A child cannot come back before its parent, because its address needs it.
|
||||
try {
|
||||
$this->pages->restorePage($child);
|
||||
self::fail('Expected the child to wait for its parent.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
$this->pages->restorePage($parent);
|
||||
$this->pages->restorePage($child);
|
||||
self::assertSame(200, $this->request('/leistungen/webdesign')->getStatusCode());
|
||||
// Only the post is left in the bin; restoring one page does not restore another.
|
||||
self::assertSame([$post], array_column($this->pages->trash(), 'id'));
|
||||
}
|
||||
|
||||
public function testRestoringARevisionKeepsTheWholeHistory(): void
|
||||
{
|
||||
$first = DemoContent::starter('Erste Fassung');
|
||||
$second = DemoContent::starter('Zweite Fassung');
|
||||
$this->pages->saveDraft('page-1', $first, 0);
|
||||
$this->pages->saveDraft('page-1', $second, 1);
|
||||
$revisions = $this->pages->revisions('page-1');
|
||||
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions, 'title'), 'Newest first.');
|
||||
self::assertTrue($revisions[0]['draft']);
|
||||
|
||||
$oldest = end($revisions);
|
||||
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
|
||||
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
|
||||
// Restoring adds a revision instead of overwriting one, so nothing is lost.
|
||||
self::assertCount(3, $this->pages->revisions('page-1'));
|
||||
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')[1]['title']);
|
||||
|
||||
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->restoreRevision('page-1', $foreign, 3);
|
||||
}
|
||||
|
||||
public function testScheduledPublicationHappensOnlyWhenItIsDue(): void
|
||||
{
|
||||
$id = $this->pages->createPage('geplant', null, 'page', DemoContent::starter('Geplant'));
|
||||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() + 86400), $this->pages->findForEditor($id)['version']);
|
||||
self::assertSame([], $this->pages->publishDue(), 'A future date publishes nothing.');
|
||||
self::assertSame(404, $this->request('/geplant')->getStatusCode());
|
||||
|
||||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($id)['version']);
|
||||
self::assertSame(['geplant'], $this->pages->publishDue());
|
||||
self::assertSame(200, $this->request('/geplant')->getStatusCode());
|
||||
// The schedule is consumed, so a later run does not publish again.
|
||||
self::assertNull($this->db->fetchOne('SELECT publish_at FROM page WHERE id = ?', [$id]));
|
||||
self::assertSame([], $this->pages->publishDue());
|
||||
|
||||
// A trashed page is never published by the schedule.
|
||||
$trashed = $this->pages->createPage('versteckt', null, 'page', DemoContent::starter('Versteckt'));
|
||||
$this->pages->schedule($trashed, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($trashed)['version']);
|
||||
$this->pages->trashPage($trashed, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$trashed]));
|
||||
self::assertSame([], $this->pages->publishDue());
|
||||
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->schedule($id, '12.09.2026', $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
|
||||
}
|
||||
|
||||
private function publishedChild(string $slug, string $title, string $parentId): string
|
||||
{
|
||||
$id = $this->pages->createPage($slug, $parentId, 'page', DemoContent::starter($title));
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
@@ -20,6 +20,7 @@ use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
@@ -55,11 +56,13 @@ final class EditorSecurityTest extends WebTestCase
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($this->db);
|
||||
Version20260912000600::applyIndexes($this->db);
|
||||
Version20260912000900::applyIndexes($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator());
|
||||
|
||||
@@ -16,6 +16,7 @@ use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
@@ -34,11 +35,13 @@ $schema = new Schema();
|
||||
(new Version20260912000600($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($db);
|
||||
Version20260912000600::applyIndexes($db);
|
||||
Version20260912000900::applyIndexes($db);
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$pages = new PageRepository($db, new DocumentValidator());
|
||||
foreach (DemoContent::pages() as $slug => $document) {
|
||||
|
||||
Reference in new issue
Block a user