A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
15 KiB
PHP
321 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidPage;
|
|
use App\Domain\StaleWrite;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
/**
|
|
* Post metadata and the terms that group posts. Metadata describes the page, not its content, so
|
|
* it lives in page columns instead of the block document: the document schema and the editor stay
|
|
* untouched, and the archive can order and paginate in SQL instead of decoding every document.
|
|
*/
|
|
final class PostRepository
|
|
{
|
|
public const KINDS = ['category' => '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<string> $categories term names, created on first use
|
|
* @param list<string> $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<array>, 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<string> $pageIds
|
|
*
|
|
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
|
|
*/
|
|
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<string, list<array{slug: string, name: string, href: string}>>
|
|
*/
|
|
/**
|
|
* 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<array{id: string, kind: string, slug: string, name: string}> */
|
|
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]);
|
|
}
|
|
}
|