'Kategorie', 'tag' => 'Schlagwort']; public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort']; public const PER_PAGE = 10; public function __construct(private Connection $db, private MediaLibrary $media, private SiteSettings $settings) { } /** * @param list $categories term names, created on first use * @param list $tags term names, created on first use */ public function saveMetadata(string $pageId, array $values, array $categories, array $tags, int $expectedVersion): void { $excerpt = trim((string) ($values['excerpt'] ?? '')); if (mb_strlen($excerpt) > 500) { throw new InvalidPage('Der Auszug darf höchstens 500 Zeichen lang sein.'); } $author = trim((string) ($values['author'] ?? '')); if (mb_strlen($author) > 120) { throw new InvalidPage('Der Autorname darf höchstens 120 Zeichen lang sein.'); } $date = trim((string) ($values['publishedAt'] ?? '')); if ('' !== $date && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?$/D', $date)) { throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.'); } $mediaId = trim((string) ($values['featuredMediaId'] ?? '')); $ogMediaId = trim((string) ($values['ogMediaId'] ?? '')); foreach (['Beitragsbild' => $mediaId, 'Vorschaubild' => $ogMediaId] as $label => $candidate) { if ('' !== $candidate && (1 !== preg_match('/^[a-f0-9]{32}$/D', $candidate) || null === $this->media->find($candidate))) { throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label)); } } // Der Titel fuer die Suche. Leer heisst: der Titel der Seite gilt. 160 Zeichen sind die // Grenze des Feldes; Google zeigt etwa 60 - der Hinweis dazu steht im Formular. $seoTitle = trim((string) ($values['seoTitle'] ?? '')); if (mb_strlen($seoTitle) > 160) { throw new InvalidPage('Der Titel für Suchergebnisse darf höchstens 160 Zeichen lang sein.'); } $robots = (string) ($values['robots'] ?? 'index'); if (!in_array($robots, ['index', 'noindex'], true)) { throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.'); } $this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $seoTitle, $categories, $tags, $expectedVersion): void { $changed = $this->db->executeStatement( 'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, seo_title = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?', [ '' === $excerpt ? null : $excerpt, '' === $author ? null : $author, '' === $mediaId ? null : $mediaId, '' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00', $robots, '' === $seoTitle ? null : $seoTitle, '' === $ogMediaId ? null : $ogMediaId, $pageId, $expectedVersion, ], ); if (1 !== $changed) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); foreach (['category' => $categories, 'tag' => $tags] as $kind => $names) { foreach ($names as $name) { $this->db->insert('page_term', ['page_id' => $pageId, 'term_id' => $this->term($kind, $name)]); } } }); } /** Finds or creates a term and returns its ID. Names are matched by their slug. */ private function term(string $kind, string $name, string $locale = 'de'): string { $name = trim($name); $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'])) ?? '', '-'); if ('' === $slug || mb_strlen($name) > 120) { throw new InvalidPage(sprintf('„%s“ ist als %s nicht verwendbar.', $name, self::KINDS[$kind])); } $existing = $this->db->fetchOne('SELECT id FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, substr($slug, 0, 80)]); if (false !== $existing) { return $existing; } $id = bin2hex(random_bytes(16)); $this->db->insert('term', ['id' => $id, 'website_id' => 'default', 'locale' => $locale, 'kind' => $kind, 'slug' => substr($slug, 0, 80), 'name' => $name]); return $id; } /** Stamps the publication date when a post first goes public and has no date of its own. */ public function stampPublication(string $pageId): void { $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], ); } /** * @return array{posts: list, total: int, pages: int, page: int} */ /** * @param string|null $language eine weitere Sprache (ADR 111): Titel und Auszug kommen aus * der Übersetzung des Beitrags, sofern er eine hat, die Namen der * Kategorien aus den Einstellungen */ public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de', ?string $language = null): array { $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) { $join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?'; array_unshift($params, $termId); } $total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page p'.$join.' WHERE '.$where, $params); $pages = max(1, (int) ceil($total / $perPage)); $page = max(1, min($page, $pages)); $rows = $this->db->fetchAllAssociative( 'SELECT p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p' .$join.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE '.$where // A missing date sorts last, then newest first, with the path as a stable tiebreaker. .' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC LIMIT '.$perPage.' OFFSET '.(($page - 1) * $perPage), $params, ); // Bilder und Begriffe für die ganze Liste in je einer Abfrage: vorher fragte jeder Beitrag // einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete // also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd. $images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows)); $terms = $this->termsForMany(array_column($rows, 'id'), $language); return [ 'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []], $language), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page, ]; } private function present(array $row, array $images, array $terms, ?string $language = null): array { $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $item = $images[$row['featured_media_id']] ?? null; // Übersetzt ist ein Beitrag, wenn seine veröffentlichte Fassung die Sprache trägt. Sonst // steht er in der Hauptsprache in der Liste - wie jedes nicht übersetzte Feld. $translated = null !== $language && in_array($language, Translations::locales($document), true); $excerpt = $row['excerpt'] ?? $document['description'] ?? ''; if ($translated) { $excerpt = $document['translations'][$language]['description'] ?? $excerpt; $document = Translations::apply($document, $language); } return [ 'title' => $document['title'] ?? $row['path'], 'href' => $this->settings->publicPath($row['path']), 'excerpt' => $excerpt, 'author' => $row['author'], 'publishedAt' => $row['published_at'], 'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']], 'terms' => $terms, ]; } /** * Die Begriffe mehrerer Seiten auf einmal. * * @param list $pageIds * * @return array>> */ private function termsForMany(array $pageIds, ?string $language = null): array { $pageIds = array_values(array_unique(array_filter($pageIds))); if ([] === $pageIds) { return []; } $terms = []; foreach ($this->db->fetchAllAssociative( 'SELECT pt.page_id, t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id IN (' .implode(',', array_fill(0, count($pageIds), '?')).') ORDER BY t.name', $pageIds, ) as $row) { $terms[$row['page_id']] ??= ['category' => [], 'tag' => []]; $terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; } return $terms; } /** * Der Name einer Kategorie oder eines Schlagworts in einer weiteren Sprache, gepflegt unter * Einstellungen → Sprachen (ADR 111). Ohne Übersetzung der Name der Hauptsprache. * * @param array{kind: string, slug: string, name: string} $term */ public function termName(array $term, ?string $language): string { if (null === $language) { return $term['name']; } $name = $this->settings->load()['termNames'][$language][$term['kind'].':'.$term['slug']] ?? ''; return is_string($name) && '' !== $name ? $name : $term['name']; } /** * @return array> */ /** * Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs. * * Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt * führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt * die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger, * und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation. * * @return array{previous: ?array, next: ?array} */ public function neighbours(string $pageId, string $locale = 'de', ?string $language = null): array { $termId = (string) $this->db->fetchOne( 'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1', [$pageId, 'category'], ); // Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier. $all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts']; // present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse. // Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist. $own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId])); $index = null; foreach ($all as $position => $post) { if ($post['href'] === $own) { $index = $position; break; } } if (null === $index) { return ['previous' => null, 'next' => null]; } return [ // „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht. 'previous' => $all[$index - 1] ?? null, 'next' => $all[$index + 1] ?? null, ]; } /** * Gibt es überhaupt einen veröffentlichten Beitrag? * * Eine Website ohne Blog soll nirgends von Beiträgen reden: kein leeres Archiv, kein Feed mit * null Einträgen, kein Kasten „hier erscheinen Beiträge“ auf einer Seite. Die Antwort wird für * die Dauer einer Anfrage gemerkt, weil mehrere Stellen sie brauchen. */ public function anyPublished(string $locale = 'de'): bool { return $this->anyPublished ??= (bool) $this->db->fetchOne( "SELECT 1 FROM page WHERE website_id = ? AND locale = ? AND type = 'post' AND published_revision_id IS NOT NULL AND deleted_at IS NULL LIMIT 1", ['default', $locale], ); } private ?bool $anyPublished = null; public function termsFor(string $pageId, ?string $language = null): array { $terms = ['category' => [], 'tag' => []]; foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) { $terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; } return $terms; } public function findTerm(string $kind, string $slug, string $locale = 'de'): ?array { $row = $this->db->fetchAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, $slug]); return false === $row ? null : $row; } /** @return list */ public function allTerms(string $locale = 'de'): array { return $this->db->fetchAllAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? ORDER BY kind, name', ['default', $locale]); } /** Term names of one page, as the comma separated list the metadata form edits. */ public function namesFor(string $pageId): array { $terms = $this->termsFor($pageId); return [ 'category' => implode(', ', array_column($terms['category'], 'name')), 'tag' => implode(', ', array_column($terms['tag'], 'name')), ]; } /** Removes term assignments of a deleted page. Unused terms stay; they are reusable labels. */ public function forget(string $pageId): void { $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); } }