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:
kantarciandClaude Opus 5 committed 2026-09-12 14:45:09 +02:00
1 parent ba56add678
commit 6afad30956
16 files changed
+750 -17

No files matched your search

+68 -3
View File
@@ -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 %}
+45
View File
@@ -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 %}