80) { throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); } if (!in_array($type, ['page', 'post'], true)) { throw new InvalidPage('Unbekannter Inhaltstyp.'); } $document = $this->richText->normaliseDocument($document); $this->validator->validate($document, writing: true); return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string { $path = $slug; if (null !== $parentId) { $parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $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.'); } if (substr_count($parent['path'], '/') + 2 > self::MAX_DEPTH) { throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen.', self::MAX_DEPTH)); } $path = $parent['path'].'/'.$slug; } elseif (in_array($slug, self::RESERVED_SLUGS, true)) { throw new InvalidPage('Diese Adresse ist für das CMS reserviert.'); } elseif (in_array($slug, $this->languageCodes(), true)) { throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug)); } if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) { throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path)); } $pageId = bin2hex(random_bytes(16)); $revisionId = bin2hex(random_bytes(16)); $this->db->insert('page', [ 'id' => $pageId, 'website_id' => 'default', 'locale' => $locale, 'slug' => $slug, 'path' => $path, 'parent_id' => $parentId, 'type' => $type, 'draft_revision_id' => $revisionId, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s'), ]); $this->db->insert('revision', [ '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 ($page['path'] === $this->settings->homePath()) { // The root answers at "/". Renaming it would move the whole site to a new address, // which is a settings decision, not an editing one. throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } $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; } elseif (in_array($slug, $this->languageCodes(), true)) { throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $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]); // Die Übersetzungen hängen an derselben Hierarchie: unter einer verschobenen Seite // ändern sich auch die Adressen der anderen Sprachen. if ($this->multilingual()) { $this->writeTranslationPaths($this->translationPaths(), $redirects); } 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 */ /** * Ob die Seite - mit $subtree auch eine ihrer Unterseiten - öffentlich ist. Was eine solche * Seite live verändert (Adresse, Papierkorb, Bereiche, Beitragsdaten), darf nur, wer auch * veröffentlichen darf (ADR 116). */ public function isLive(string $pageId, bool $subtree = false): bool { $page = $this->db->fetchAssociative('SELECT path, locale, published_revision_id FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$pageId, 'default']); if (false === $page) { return false; } if (null !== $page['published_revision_id'] || !$subtree) { return null !== $page['published_revision_id']; } return false !== $this->db->fetchOne( 'SELECT id FROM page WHERE website_id = ? AND locale = ? AND path LIKE ? AND deleted_at IS NULL AND published_revision_id IS NOT NULL LIMIT 1', ['default', $page['locale'], $page['path'].'/%'], ); } 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 ($page['path'] === $this->settings->homePath()) { throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } 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 */ 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 */ /** * One page of the history, newest first. * * It used to stop at thirty without saying so - which looks like "there are thirty" and is * the one thing a history must not do. * * @return array{items: list, page: int, pages: int, total: int} */ public function revisions(string $pageId, int $number = 1, int $perPage = 25): array { $slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$pageId]), $perPage); $page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]); $items = 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 '.$slice['perPage'].' OFFSET '.$slice['offset'], [$pageId], )); return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']]; } /** * 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 { return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion); } /** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */ public function revisionDocument(string $pageId, string $revisionId): array { $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 json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } /** * 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 the paths that went public */ public function publishDue(): array { $published = []; foreach ($this->db->fetchAllAssociative( // Online geht die Fassung, die beim Planen vorlag (ADR 116) - nicht ein Entwurf, den // danach jemand ohne Recht zum Veröffentlichen gespeichert hat. Planungen aus der Zeit // vor dieser Spalte tragen keine Fassung; für sie bleibt es beim Entwurf. 'SELECT id, path, COALESCE(publish_revision_id, draft_revision_id) AS revision, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND COALESCE(publish_revision_id, draft_revision_id) IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> COALESCE(publish_revision_id, draft_revision_id))', ['default', gmdate('Y-m-d H:i:s')], ) as $row) { try { $this->publish($row['id'], $row['revision'], (int) $row['version']); } catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) { // Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die // anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin. error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage())); continue; } $this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = 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.'); } // Mit dem Termin wird der jetzige Entwurf festgehalten: genau er geht online. $changed = '' === $when ? $this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL, version = version + 1 WHERE id = ? AND version = ?', [$pageId, $expectedVersion]) : $this->db->executeStatement( 'UPDATE page SET publish_at = ?, publish_revision_id = draft_revision_id, version = version + 1 WHERE id = ? AND version = ?', [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. */ public function deletePage(string $pageId, int $expectedVersion): void { $this->db->transactional(function () use ($pageId, $expectedVersion): void { $page = $this->db->fetchAssociative('SELECT 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 (0 !== (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE parent_id = ?', [$pageId])) { throw new InvalidPage('Diese Seite hat Unterseiten. Verschiebe oder lösche zuerst die Unterseiten.'); } // Detach both pointers first: the revision rows are referenced by the page row. $this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); if ($this->multilingual() || $this->db->createSchemaManager()->tablesExist(['page_translation'])) { $this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ?', [$pageId]); } $this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]); }); } public function saveDraft(string $pageId, array $document, int $expectedVersion): string { // Sanitising happens before validating and before storing, so the database never holds // markup the allowlist would reject and no renderer has to remember to filter. $document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText); $this->validator->validate($document, writing: true); return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string { // Eine übersetzte Adresse, die schon vergeben ist, fällt hier auf und nicht erst beim // Veröffentlichen. Geschrieben wird sie erst dann - bis dahin ist sie Entwurf. if ($this->multilingual($document)) { $this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)); } $id = bin2hex(random_bytes(16)); $changed = $this->db->executeStatement( 'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?', [$id, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before saving.'); } $this->db->insert('revision', [ '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; }); } public function publish(string $pageId, string $revisionId, int $expectedVersion): void { $this->db->transactional(function () use ($pageId, $revisionId, $expectedVersion): void { $raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]); if (false === $raw) { throw new \InvalidArgumentException('Revision does not belong to this page.'); } $document = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($document); $changed = $this->db->executeStatement( 'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?', [$revisionId, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before publishing.'); } // Mit der Fassung gehen auch ihre Übersetzungen online - oder offline, wenn sie keine // mehr hat. Die Adressen spiegeln damit genau den veröffentlichten Stand. $this->publishTranslations($pageId, $document); // A post that goes public without its own date gets one, so the archive can order it. $this->db->executeStatement( "UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL", [gmdate('Y-m-d H:i:s'), $pageId], ); }); } 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.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, 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) { return null; } $page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($page['document']); $page['document'] = Translations::strip($page['document']); return $page; } /** * Eine veröffentlichte Seite in einer weiteren Sprache, gefunden über ihre übersetzte * Adresse. Das Dokument kommt mit den übersetzten Texten; `path` bleibt die Adresse der * Hauptsprache, weil Navigation, Brotkrumen und Formulare an ihr hängen. */ public function findPublishedTranslation(string $path, string $locale): ?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.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name, t.path AS translated_path FROM page_translation t JOIN page p ON p.id = t.page_id 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 t.path = ? AND t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL', [$path, $locale, 'default'], ); if (false === $page) { return null; } $document = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($document); if (!in_array($locale, Translations::locales($document), true)) { return null; } $translation = $document['translations'][$locale]; $page['document'] = Translations::apply($document, $locale); $page['main_locale'] = $page['locale']; $page['locale'] = $locale; // Ohne eigenen Suchtitel nimmt die Vorlage den übersetzten Seitentitel - nie den Suchtitel // der Hauptsprache, der in der falschen Sprache in den Suchergebnissen stünde. $page['seo_title'] = $translation['seoTitle'] ?? ''; return $page; } /** * Unter welchen Adressen eine Seite in den weiteren Sprachen veröffentlicht ist, als Pfad * ohne Schrägstrich vorn. * * @return array Sprache => Pfad */ public function translatedPaths(string $pageId): array { $paths = []; foreach ($this->db->fetchAllAssociative('SELECT locale, path FROM page_translation WHERE page_id = ? AND website_id = ? ORDER BY locale', [$pageId, 'default']) as $row) { $paths[$row['locale']] = $row['path']; } return $paths; } /** * Alle veröffentlichten Seiten einer Sprache: Pfad der Hauptsprache => übersetzter Pfad. * Damit zeigen Links auf einer übersetzten Seite auf die übersetzten Fassungen. * * @return array */ public function translationMap(string $locale): array { $map = []; foreach ($this->db->fetchAllAssociative( 'SELECT p.path AS main_path, t.path FROM page_translation t JOIN page p ON p.id = t.page_id WHERE t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL AND p.published_revision_id IS NOT NULL', [$locale, 'default'], ) as $row) { $map[$row['main_path']] = $row['path']; } return $map; } /** * Die weiteren Sprachen, direkt gelesen: über SiteSettings::load() bliebe sonst eine ältere * Startseite für den Rest der Anfrage zwischengespeichert. * * @return list */ private function languageCodes(): array { $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); $settings = is_string($raw) ? json_decode($raw, true) : null; return is_array($settings['languages'] ?? null) ? $settings['languages'] : []; } /** * Ob die übersetzten Adressen überhaupt gepflegt werden müssen: nur bei einer Website mit * weiteren Sprachen oder einem Dokument mit Übersetzungen. Eine einsprachige Website fasst * die Tabelle so nie an - auch nicht, wenn nach einem Update die Migration noch aussteht. * Abgewählte Sprachen holt das nächste Veröffentlichen nach: es rechnet alle Pfade neu. */ private function multilingual(?array $document = null): bool { return [] !== $this->languageCodes() || (null !== $document && [] !== Translations::locales($document)); } /** @return array Sprache => übersetzte Adresse, aus dem Dokument */ private function translatedSlugs(string $pageId, array $document): array { $slug = (string) $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$pageId]); $slugs = []; foreach (Translations::locales($document) as $locale) { $slugs[$locale] = (string) ($document['translations'][$locale]['slug'] ?? $slug); } return $slugs; } /** * Schreibt die übersetzten Adressen einer gerade veröffentlichten Seite: neue Sprachen kommen * dazu, entfernte fallen weg, und eine geänderte Adresse bekommt eine Weiterleitung. */ private function publishTranslations(string $pageId, array $document): void { if ($this->multilingual($document)) { $this->writeTranslationPaths($this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)), new RedirectRepository($this->db)); } } /** * Rechnet die übersetzten Adressen aller Seiten aus. Eine Seite steht in einer Sprache unter * dem übersetzten Pfad ihrer Elternseite - oder, wenn die nicht übersetzt ist, unter deren * Pfad in der Hauptsprache - und dahinter ihre übersetzte Adresse. * * Mit $pageId ersetzt $slugs die Sprachen dieser einen Seite (Entwurf oder Veröffentlichung); * sonst gilt der gespeicherte Stand (nach dem Verschieben). Doppelte Adressen werden * abgelehnt, bevor irgendetwas geschrieben ist. * * @param array $slugs Sprache => Adresse * * @return array> Sprache => Seite => Zeile */ private function translationPaths(?string $pageId = null, array $slugs = []): array { $pages = []; foreach ($this->db->fetchAllAssociative('SELECT id, slug, parent_id FROM page WHERE website_id = ?', ['default']) as $row) { $pages[$row['id']] = $row; } $wanted = []; $old = []; foreach ($this->db->fetchAllAssociative('SELECT page_id, locale, slug, path FROM page_translation WHERE website_id = ?', ['default']) as $row) { $wanted[$row['locale']][$row['page_id']] = $row['slug']; $old[$row['locale']][$row['page_id']] = $row['path']; } if (null !== $pageId) { foreach (array_keys($wanted) as $locale) { unset($wanted[$locale][$pageId]); } foreach ($slugs as $locale => $slug) { $wanted[$locale][$pageId] = $slug; } } $result = []; foreach ($wanted as $locale => $rows) { $memo = []; $pathOf = function (string $id, int $depth = 0) use (&$pathOf, &$memo, $pages, $rows): string { if (isset($memo[$id])) { return $memo[$id]; } $page = $pages[$id] ?? null; if (null === $page || $depth > self::MAX_DEPTH) { return ''; } $segment = $rows[$id] ?? $page['slug']; $parent = null === $page['parent_id'] ? '' : $pathOf($page['parent_id'], $depth + 1); return $memo[$id] = '' === $parent ? $segment : $parent.'/'.$segment; }; $taken = []; foreach ($rows as $id => $slug) { $id = (string) $id; if (!isset($pages[$id])) { continue; } $path = $pathOf($id); if (isset($taken[$path])) { throw new InvalidPage(sprintf('Die Adresse /%s/%s ist in %s bereits vergeben. Wähle für die Übersetzung eine andere Adresse.', $locale, $path, Languages::CATALOG[$locale]['name'] ?? $locale)); } $taken[$path] = true; $result[$locale][$id] = ['slug' => $slug, 'path' => $path, 'old' => $old[$locale][$id] ?? null]; } } return $result; } /** * Bringt die Tabelle auf den ausgerechneten Stand. Geänderte Pfade wandern in zwei Schritten, * damit zwei Seiten, die ihre Adressen tauschen, nicht am eindeutigen Index scheitern. */ private function writeTranslationPaths(array $paths, RedirectRepository $redirects): void { $keep = []; foreach ($paths as $locale => $rows) { foreach (array_keys($rows) as $id) { $keep[$id.'|'.$locale] = true; } } foreach ($this->db->fetchAllAssociative('SELECT page_id, locale FROM page_translation WHERE website_id = ?', ['default']) as $row) { if (!isset($keep[$row['page_id'].'|'.$row['locale']])) { $this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ? AND locale = ?', [$row['page_id'], $row['locale']]); } } $moved = []; foreach ($paths as $locale => $rows) { foreach ($rows as $id => $row) { if (null === $row['old']) { $this->db->insert('page_translation', ['page_id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $row['slug'], 'path' => '~'.$id]); $moved[] = [$id, $locale, $row]; } elseif ($row['old'] !== $row['path']) { $this->db->executeStatement('UPDATE page_translation SET path = ? WHERE page_id = ? AND locale = ?', ['~'.$id, $id, $locale]); $moved[] = [$id, $locale, $row]; } else { $this->db->executeStatement('UPDATE page_translation SET slug = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $id, $locale]); } } } foreach ($moved as [$id, $locale, $row]) { $this->db->executeStatement('UPDATE page_translation SET slug = ?, path = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $row['path'], $id, $locale]); if (null !== $row['old']) { $redirects->remember($locale.'/'.$row['old'], $locale.'/'.$row['path']); } } } /** * Which header and footer a page wants: null for the standard one, 'none' for no area at all, * or the address of another one. */ public function saveAreas(string $pageId, ?string $header, ?string $footer, int $expectedVersion): void { foreach ([$header, $footer] as $choice) { if (null !== $choice && 'none' !== $choice && 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $choice)) { throw new InvalidPage('Die Adresse eines Bereichs darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); } } $changed = $this->db->executeStatement( 'UPDATE page SET header_area = ?, footer_area = ?, version = version + 1 WHERE id = ? AND version = ?', [$header, $footer, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Seite neu.'); } } /** A published page by ID, used to serve its own script file. */ public function findPublishedById(string $id, string $locale = 'de'): ?array { $row = $this->db->fetchAssociative( 'SELECT p.id, p.path, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, $locale, 'default'], ); if (false === $row) { return null; } $row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); return $row; } 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.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, 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']); } /** Only the requested screenful is hydrated; filters and counts run in the database. */ public function editorPage(string $type, int $page = 1, int $limit = 20, string $query = '', string $status = 'all'): array { $limit = in_array($limit, [20, 50], true) ? $limit : 20; $type = 'post' === $type ? 'post' : 'page'; $qb = $this->db->createQueryBuilder()->from('page', 'p') ->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id') ->where('p.website_id = :website')->andWhere('p.type = :type') ->setParameter('website', 'default')->setParameter('type', $type); $qb->andWhere('trash' === $status ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'); if ('draft' === $status) { $qb->andWhere('p.published_revision_id IS NULL'); } elseif ('published' === $status) { $qb->andWhere('p.published_revision_id IS NOT NULL'); } elseif ('changed' === $status) { $qb->andWhere('p.published_revision_id IS NOT NULL AND p.published_revision_id <> p.draft_revision_id'); } if ('' !== trim($query)) { $qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')') ->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%'); } $total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne(); $pages = max(1, (int) ceil($total / $limit)); $page = max(1, min($pages, $page)); $items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC') // Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“). ->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative(); return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit]; } /** Bounded suggestions for menus and parent selectors, including a selected ID lookup. */ public function choices(string $query = '', string $type = '', bool $parentsOnly = false, ?string $id = null): array { $qb = $this->db->createQueryBuilder()->select('p.id', 'p.path', 'p.type', 'p.published_revision_id', 'r.document') ->from('page', 'p')->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id') ->where('p.website_id = :website')->andWhere('p.deleted_at IS NULL')->setParameter('website', 'default'); if (null !== $id) { $qb->andWhere('p.id = :id')->setParameter('id', $id); } if ($parentsOnly || in_array($type, ['page', 'post'], true)) { $qb->andWhere('p.type = :type')->setParameter('type', $parentsOnly ? 'page' : $type); } if ($parentsOnly) { $qb->andWhere("LENGTH(p.path) - LENGTH(REPLACE(p.path, '/', '')) < ".(self::MAX_DEPTH - 1)); } if ('' !== trim($query)) { $qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')') ->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%'); } $rows = $qb->orderBy('p.path')->addOrderBy('p.id')->setMaxResults(20)->executeQuery()->fetchAllAssociative(); return array_map(static fn (array $row): array => [ 'id' => $row['id'], 'path' => $row['path'], 'type' => $row['type'], 'title' => json_decode($row['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $row['path'], 'published' => null !== $row['published_revision_id'], ], $rows); } 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 = ? AND p.deleted_at IS NULL', [$id, 'default']); if (false === $row) { return null; } $row['version'] = (int) $row['version']; $row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($row['document']); return $row; } }