Files
KantSites/src/Content/PostRepository.php
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
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>
2026-10-09 16:56:52 +02:00

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]);
}
}