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>
569 lines
29 KiB
PHP
569 lines
29 KiB
PHP
<?php
|
||
|
||
namespace App\Content;
|
||
|
||
use App\Domain\InvalidPage;
|
||
use App\Domain\StaleWrite;
|
||
use Doctrine\DBAL\Connection;
|
||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||
|
||
/**
|
||
* Company data and site wide switches. The structured data the pages emit is built from exactly
|
||
* these values, so what a search engine reads is what an editor maintains here – never a separate,
|
||
* invisible copy.
|
||
*/
|
||
final class SiteSettings
|
||
{
|
||
public const DEFAULTS = [
|
||
'legalName' => '',
|
||
'description' => '',
|
||
'phone' => '',
|
||
'email' => '',
|
||
'street' => '',
|
||
'postalCode' => '',
|
||
'city' => '',
|
||
'country' => 'DE',
|
||
'sameAs' => [],
|
||
// Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein
|
||
// `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit.
|
||
'social' => [],
|
||
// Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste
|
||
// von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät.
|
||
'openingHours' => [],
|
||
'areaServed' => [],
|
||
'localBusiness' => false,
|
||
'llmsTxt' => false,
|
||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||
'stickyHeader' => false,
|
||
// Der Kopfbereich liegt über dem Inhalt statt darüber zu stehen - der Flatsome-Aufbau mit
|
||
// transparentem Kopf über dem Hero. Welche Farben er beim Scrollen bekommt, bestimmt die
|
||
// Gestaltung des Kopfbereichs selbst („beim Scrollen" unter Hintergrund).
|
||
'headerOverlay' => false,
|
||
// Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem
|
||
// Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die
|
||
// manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat.
|
||
'motion' => 'always',
|
||
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
|
||
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
|
||
'postMetaHeader' => true,
|
||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||
'faviconMediaId' => '',
|
||
// Der Zuschnitt dazu als "links,oben,kante" in Anteilen, leer heißt: die Mitte. Die Version
|
||
// hängt am Verweis, damit ein neuer Ausschnitt im Browser ankommt.
|
||
'faviconCrop' => '',
|
||
'faviconVersion' => '',
|
||
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
|
||
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
|
||
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
|
||
'scrollTop' => false,
|
||
'scrollTopPosition' => 'rechts',
|
||
'scrollTopShape' => 'rund',
|
||
'scrollTopSize' => 48,
|
||
'scrollTopColor' => '',
|
||
'scrollTopInk' => '',
|
||
'scrollTopLabel' => 'Nach oben',
|
||
'scrollTopAfter' => 400,
|
||
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
|
||
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
|
||
'turnstile' => false,
|
||
'turnstileSiteKey' => '',
|
||
'turnstileSecret' => '',
|
||
'robotsExtra' => '',
|
||
// Which stored page answers at the site root. Everything else derives from this.
|
||
'homePath' => 'home',
|
||
'notFoundPageId' => '',
|
||
// The consent question. Off by default, because a site that loads nothing from third
|
||
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||
'consentBanner' => false,
|
||
'consentGroups' => ['external'],
|
||
'consentText' => '',
|
||
// Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard.
|
||
'consentLayout' => 'karte',
|
||
'consentShape' => 'weich',
|
||
'consentTitle' => '',
|
||
'consentColor' => '',
|
||
'consentInk' => '',
|
||
'consentAccent' => '',
|
||
'consentAccentInk' => '',
|
||
'privacyPath' => '',
|
||
'imprintPath' => '',
|
||
// Die weiteren Sprachen neben der Hauptsprache (ADR 111), gepflegt über Languages::save.
|
||
// Leer heißt: einsprachig - und dann ist nirgends etwas von Übersetzungen zu sehen.
|
||
'languages' => [],
|
||
// Namen von Kategorien und Schlagwörtern je weiterer Sprache: {en: {"category:projekte": "Projects"}}.
|
||
'termNames' => [],
|
||
// Überschrift und Text des Einwilligungshinweises je weiterer Sprache: {en: {title, text}}.
|
||
'consentTexts' => [],
|
||
];
|
||
|
||
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
|
||
public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag'];
|
||
|
||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne
|
||
* beide Angaben gilt als geschlossen und fehlt in der Liste.
|
||
*
|
||
* @return list<array{day: string, opens: string, closes: string}>
|
||
*/
|
||
private function openingHours(mixed $input): array
|
||
{
|
||
$hours = [];
|
||
if (is_array($input) && array_is_list($input)) {
|
||
// Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform.
|
||
$map = [];
|
||
foreach ($input as $row) {
|
||
if (is_array($row) && isset($row['day'])) {
|
||
$map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? ''];
|
||
}
|
||
}
|
||
$input = $map;
|
||
}
|
||
foreach (self::DAYS as $day => $label) {
|
||
$row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : [];
|
||
$opens = trim((string) ($row['opens'] ?? ''));
|
||
$closes = trim((string) ($row['closes'] ?? ''));
|
||
if ('' === $opens && '' === $closes) {
|
||
continue;
|
||
}
|
||
foreach ([$opens, $closes] as $time) {
|
||
if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) {
|
||
throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label));
|
||
}
|
||
}
|
||
if ($opens >= $closes) {
|
||
throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label));
|
||
}
|
||
$hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes];
|
||
}
|
||
|
||
return $hours;
|
||
}
|
||
|
||
/** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */
|
||
private function areaServed(string $input): array
|
||
{
|
||
$places = [];
|
||
foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) {
|
||
$place = trim($place);
|
||
if ('' === $place) {
|
||
continue;
|
||
}
|
||
if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40)));
|
||
}
|
||
$places[] = $place;
|
||
}
|
||
if (count($places) > 40) {
|
||
throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.');
|
||
}
|
||
|
||
return array_values(array_unique($places));
|
||
}
|
||
|
||
/**
|
||
* Remembered for the length of one request.
|
||
*
|
||
* These settings are asked for several times per page - by the renderer, by the structured
|
||
* data, by the consent gate, by the templates - and each time it was the same row again. The
|
||
* value is dropped on every save, so nothing stale survives a change.
|
||
*/
|
||
private ?array $loaded = null;
|
||
|
||
public function load(): array
|
||
{
|
||
if (null !== $this->loaded) {
|
||
return $this->loaded;
|
||
}
|
||
try {
|
||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||
} catch (TableNotFoundException) {
|
||
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
|
||
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
|
||
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
|
||
return $this->loaded = self::DEFAULTS;
|
||
}
|
||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||
|
||
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||
}
|
||
|
||
public function save(array $input): void
|
||
{
|
||
$values = self::DEFAULTS;
|
||
// Assigned separately, so saving company data cannot clear the error page.
|
||
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
|
||
// Ebenso die Sprachen: sie haben einen eigenen Bildschirm mit eigener Prüfung.
|
||
$values['languages'] = $this->load()['languages'];
|
||
$values['termNames'] = $this->load()['termNames'];
|
||
$values['consentTexts'] = $this->load()['consentTexts'];
|
||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||
}
|
||
if ('' !== $values['email'] && false === filter_var($values['email'], FILTER_VALIDATE_EMAIL)) {
|
||
throw new InvalidPage('Bitte gib eine gültige E-Mail-Adresse an.');
|
||
}
|
||
if ('' !== $values['phone'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $values['phone'])) {
|
||
throw new InvalidPage('Bitte gib eine gültige Telefonnummer an.');
|
||
}
|
||
$country = strtoupper(trim((string) ($input['country'] ?? 'DE')));
|
||
if (1 !== preg_match('/^[A-Z]{2}$/D', $country)) {
|
||
throw new InvalidPage('Das Land wird als zweibuchstabiger Code angegeben, zum Beispiel DE.');
|
||
}
|
||
$values['country'] = $country;
|
||
foreach (preg_split('/[\r\n,]+/', (string) ($input['sameAs'] ?? '')) ?: [] as $profile) {
|
||
$profile = trim($profile);
|
||
if ('' === $profile) {
|
||
continue;
|
||
}
|
||
if (!str_starts_with($profile, 'https://') || false === filter_var($profile, FILTER_VALIDATE_URL)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist keine HTTPS-Adresse.', $profile));
|
||
}
|
||
$values['sameAs'][] = $profile;
|
||
}
|
||
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
|
||
$values['social'] = SocialProfiles::clean($input['social'] ?? []);
|
||
// Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und
|
||
// die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm
|
||
// die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht
|
||
// das Anlegen der Rechtsseiten die Öffnungszeiten.
|
||
$values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []);
|
||
$areaInput = $input['areaServed'] ?? '';
|
||
$values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode('
|
||
', array_map('strval', $areaInput)) : (string) $areaInput);
|
||
$values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
|
||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||
}
|
||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||
$crop = '' === $favicon ? '' : trim((string) ($input['faviconCrop'] ?? ''));
|
||
$anteile = null;
|
||
if ('' !== $crop) {
|
||
$anteile = array_map('floatval', explode(',', $crop));
|
||
if (1 !== preg_match('/^[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?$/D', $crop) || max($anteile) > 1 || $anteile[2] < 0.02) {
|
||
throw new InvalidPage('Der Ausschnitt für das Symbol ist ungültig. Bitte das Bild neu zuschneiden.');
|
||
}
|
||
}
|
||
if ('' !== $favicon) {
|
||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
|
||
}
|
||
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
|
||
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
|
||
// wäre schlimmer als das alte Symbol.
|
||
try {
|
||
$this->media->renderFavicon($favicon, $anteile);
|
||
} catch (\RuntimeException $error) {
|
||
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
|
||
}
|
||
}
|
||
$values['faviconMediaId'] = $favicon;
|
||
$values['faviconCrop'] = $crop;
|
||
// Ohne Zuschnitt die Kennung wie bisher, damit bestehende Verweise gleich bleiben.
|
||
$values['faviconVersion'] = '' === $favicon ? '' : ('' === $crop ? substr($favicon, 0, 8) : substr(md5($favicon.'|'.$crop), 0, 8));
|
||
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
|
||
$wert = trim((string) ($input[$key] ?? ''));
|
||
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
|
||
throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.');
|
||
}
|
||
$values[$key] = $wert;
|
||
}
|
||
$values['scrollTop'] = (bool) ($input['scrollTop'] ?? false);
|
||
$values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts';
|
||
$values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund';
|
||
$values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48)));
|
||
// Ab wie vielen Pixeln er erscheint. Null heisst: sofort.
|
||
$values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400)));
|
||
$values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben';
|
||
foreach (['scrollTopColor', 'scrollTopInk'] as $feld) {
|
||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||
throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||
}
|
||
$values[$feld] = $farbe;
|
||
}
|
||
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
|
||
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
|
||
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
|
||
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
|
||
}
|
||
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
||
$values['motion'] = in_array($input['motion'] ?? '', ['always', 'respect'], true) ? $input['motion'] : 'always';
|
||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
|
||
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
|
||
// liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig.
|
||
$extra = [];
|
||
foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
|
||
$line = trim($line);
|
||
if ('' === $line) {
|
||
if ([] !== $extra && '' !== end($extra)) {
|
||
$extra[] = '';
|
||
}
|
||
|
||
continue;
|
||
}
|
||
if (1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
|
||
$extra[] = $line;
|
||
}
|
||
}
|
||
$values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200)));
|
||
|
||
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
|
||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
|
||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||
}
|
||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
|
||
if (false === $homeId) {
|
||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||
}
|
||
if ($homeId === $values['notFoundPageId']) {
|
||
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
|
||
}
|
||
$values['homePath'] = $home;
|
||
|
||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
||
$values['consentGroups'] = array_values(array_intersect(
|
||
array_keys(Consent::GROUPS),
|
||
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
|
||
));
|
||
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
|
||
$values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte';
|
||
$values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich';
|
||
$values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80);
|
||
foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) {
|
||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||
throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||
}
|
||
$values[$feld] = $farbe;
|
||
}
|
||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||
$path = trim(trim((string) ($input[$key] ?? '')), '/');
|
||
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
|
||
throw new InvalidPage('Die Adresse der Datenschutz- beziehungsweise Impressumsseite darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
||
}
|
||
$values[$key] = $path;
|
||
}
|
||
// Privacy statement and imprint are not a feature that can be switched off: a German
|
||
// business website needs both, and both have to be reachable from every page. What the
|
||
// CMS cannot do is write them, so it only insists that the pages exist.
|
||
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
|
||
if ('' !== $values[$key] && false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $values[$key]])) {
|
||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s für die %s.', $values[$key], $label));
|
||
}
|
||
}
|
||
if ($values['consentBanner']) {
|
||
if ([] === $values['consentGroups']) {
|
||
throw new InvalidPage('Ohne eine Gruppe gibt es nichts zu fragen. Wähle mindestens eine, oder schalte den Hinweis ab.');
|
||
}
|
||
// A consent banner without a way to read the details is not consent.
|
||
if ('' === $values['privacyPath']) {
|
||
throw new InvalidPage('Ein Einwilligungshinweis braucht die Adresse der Datenschutzseite.');
|
||
}
|
||
}
|
||
|
||
$this->loaded = null;
|
||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'default',
|
||
]);
|
||
}
|
||
|
||
/** Change only the 404 assignment, preserving settings saved in other forms. */
|
||
public function assignNotFoundPage(string $id, string $expectedId): void
|
||
{
|
||
$this->db->transactional(function () use ($id, $expectedId): void {
|
||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
|
||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||
$stored = is_array($stored) ? $stored : [];
|
||
$current = $stored['notFoundPageId'] ?? '';
|
||
if ($current !== $expectedId) {
|
||
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
|
||
}
|
||
if ('' !== $id) {
|
||
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
|
||
if (false === $page || 'page' !== $page['type']) {
|
||
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
|
||
}
|
||
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
|
||
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
|
||
}
|
||
}
|
||
if ($current === $id) {
|
||
return;
|
||
}
|
||
$stored['notFoundPageId'] = $id;
|
||
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||
]);
|
||
if (1 !== $changed) {
|
||
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
|
||
}
|
||
});
|
||
$this->loaded = null;
|
||
}
|
||
|
||
/**
|
||
* Nur die Sprachen ändern, alles andere unberührt lassen. Geprüft wird in Languages::save.
|
||
*
|
||
* @param list<string> $codes
|
||
*/
|
||
public function saveLanguages(array $codes): void
|
||
{
|
||
$this->db->transactional(function () use ($codes): void {
|
||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||
$stored = is_array($stored) ? $stored : [];
|
||
$stored['languages'] = array_values($codes);
|
||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||
]);
|
||
});
|
||
$this->loaded = null;
|
||
}
|
||
|
||
/**
|
||
* Die Texte der Einstellungen in weiteren Sprachen (ADR 111): Namen von Kategorien und
|
||
* Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises. Alles andere bleibt.
|
||
*
|
||
* @param array<string, array<string, string>> $names Sprache => "art:adresse" => Name
|
||
* @param array<string, array<string, string>> $consent Sprache => {title, text}
|
||
*/
|
||
public function saveLanguageTexts(array $names, array $consent = []): void
|
||
{
|
||
$consentClean = [];
|
||
foreach ($consent as $locale => $texts) {
|
||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($texts)) {
|
||
continue;
|
||
}
|
||
foreach (['title' => 80, 'text' => 600] as $key => $max) {
|
||
$text = mb_substr(trim((string) ($texts[$key] ?? '')), 0, $max);
|
||
if ('' !== $text) {
|
||
$consentClean[$locale][$key] = $text;
|
||
}
|
||
}
|
||
}
|
||
$clean = [];
|
||
foreach ($names as $locale => $entries) {
|
||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($entries)) {
|
||
continue;
|
||
}
|
||
foreach ($entries as $key => $name) {
|
||
$name = mb_substr(trim((string) $name), 0, 120);
|
||
if ('' !== $name && 1 === preg_match('/^(category|tag):[a-z0-9-]{1,80}$/D', (string) $key)) {
|
||
$clean[$locale][(string) $key] = $name;
|
||
}
|
||
}
|
||
}
|
||
$this->db->transactional(function () use ($clean, $consentClean): void {
|
||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||
$stored = is_array($stored) ? $stored : [];
|
||
$stored['termNames'] = $clean;
|
||
$stored['consentTexts'] = $consentClean;
|
||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||
]);
|
||
});
|
||
$this->loaded = null;
|
||
}
|
||
|
||
/**
|
||
* Which of the two mandatory pages are missing, by their settings key.
|
||
*
|
||
* @return array<string, string> key => label
|
||
*/
|
||
public function missingLegalPages(): array
|
||
{
|
||
$settings = $this->load();
|
||
$missing = [];
|
||
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
|
||
if ('' === $settings[$key]) {
|
||
$missing[$key] = $label;
|
||
}
|
||
}
|
||
|
||
return $missing;
|
||
}
|
||
|
||
/** The stored path of the page that answers at the site root. */
|
||
public function homePath(): string
|
||
{
|
||
return $this->load()['homePath'];
|
||
}
|
||
|
||
/**
|
||
* Turns a stored page path into its public address. The root page answers at "/" and must not
|
||
* also be reachable under its own path, or the same content would live at two addresses.
|
||
*/
|
||
public function publicPath(string $storedPath, ?string $homePath = null): string
|
||
{
|
||
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
||
}
|
||
|
||
/**
|
||
* Structured data for the organisation, built from the maintained values only. Returns null
|
||
* while nothing is maintained: an empty Organization block would be noise, not data.
|
||
*/
|
||
public function organisation(array $settings, string $origin, string $siteName): ?array
|
||
{
|
||
$hasAddress = '' !== $settings['street'] && '' !== $settings['city'];
|
||
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
||
return null;
|
||
}
|
||
$data = [
|
||
'@context' => 'https://schema.org',
|
||
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
|
||
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
||
'url' => $origin.'/',
|
||
];
|
||
foreach (['description' => 'description', 'phone' => 'telephone', 'email' => 'email'] as $key => $property) {
|
||
if ('' !== $settings[$key]) {
|
||
$data[$property] = $settings[$key];
|
||
}
|
||
}
|
||
if ($hasAddress) {
|
||
$data['address'] = array_filter([
|
||
'@type' => 'PostalAddress',
|
||
'streetAddress' => $settings['street'],
|
||
'postalCode' => $settings['postalCode'],
|
||
'addressLocality' => $settings['city'],
|
||
'addressCountry' => $settings['country'],
|
||
], static fn (string $value): bool => '' !== $value);
|
||
}
|
||
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
|
||
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
||
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
||
if ([] !== $sameAs) {
|
||
$data['sameAs'] = $sameAs;
|
||
}
|
||
// Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile,
|
||
// nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert.
|
||
$gruppen = [];
|
||
foreach ($settings['openingHours'] ?? [] as $eintrag) {
|
||
$schluessel = $eintrag['opens'].'-'.$eintrag['closes'];
|
||
$gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']];
|
||
$gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day'];
|
||
}
|
||
if ([] !== $gruppen) {
|
||
$data['openingHoursSpecification'] = array_values($gruppen);
|
||
}
|
||
if ([] !== ($settings['areaServed'] ?? [])) {
|
||
$data['areaServed'] = $settings['areaServed'];
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
}
|