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

+1 -1
View File
@@ -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;
}
+214 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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) {