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>
This commit is contained in:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
+145
-13
@@ -322,6 +322,10 @@ final class FormRepository
|
||||
// Skript steht das ganze Formular ohnehin auf einer Seite.
|
||||
'summary' => (bool) ($settings['summary'] ?? false),
|
||||
];
|
||||
$translations = $this->validateTranslations($settings['translations'] ?? [], $fields);
|
||||
if ([] !== $translations) {
|
||||
$clean['translations'] = $translations;
|
||||
}
|
||||
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
|
||||
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
|
||||
foreach (self::COLORS as $field => $label) {
|
||||
@@ -335,6 +339,130 @@ final class FormRepository
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld
|
||||
* Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was
|
||||
* Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen,
|
||||
* Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben.
|
||||
*/
|
||||
private function validateTranslations(mixed $input, array $fields): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.');
|
||||
}
|
||||
$byName = array_column($fields, null, 'name');
|
||||
$clean = [];
|
||||
foreach ($input as $locale => $translation) {
|
||||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) {
|
||||
throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.');
|
||||
}
|
||||
$out = [];
|
||||
foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) {
|
||||
$text = trim((string) ($translation[$key] ?? ''));
|
||||
if ('' !== $text) {
|
||||
$out[$key] = mb_substr($text, 0, $max);
|
||||
}
|
||||
}
|
||||
foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) {
|
||||
$field = $byName[$name] ?? null;
|
||||
if (null === $field || !is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
$entry = [];
|
||||
foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) {
|
||||
$text = trim((string) ($values[$key] ?? ''));
|
||||
if ('' !== $text) {
|
||||
$entry[$key] = mb_substr($text, 0, $max);
|
||||
}
|
||||
}
|
||||
foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) {
|
||||
$text = trim((string) $text);
|
||||
if (in_array((string) $original, $field['options'], true) && '' !== $text) {
|
||||
$entry['options'][(string) $original] = mb_substr($text, 0, 120);
|
||||
}
|
||||
}
|
||||
if ([] !== $entry) {
|
||||
$out['fields'][$name] = $entry;
|
||||
}
|
||||
}
|
||||
if ([] !== $out) {
|
||||
$clean[$locale] = $out;
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und
|
||||
* Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die
|
||||
* Anzeige steht in `optionLabels`.
|
||||
*/
|
||||
public static function localise(array $form, ?string $locale): array
|
||||
{
|
||||
$translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []);
|
||||
foreach ($form['fields'] as $index => $field) {
|
||||
$values = $translation['fields'][$field['name']] ?? [];
|
||||
foreach (['label', 'help', 'unit'] as $key) {
|
||||
if (isset($values[$key])) {
|
||||
$form['fields'][$index][$key] = $values[$key];
|
||||
}
|
||||
}
|
||||
$form['fields'][$index]['optionLabels'] = $values['options'] ?? [];
|
||||
}
|
||||
foreach (['success', 'buttonLabel'] as $key) {
|
||||
if (isset($translation[$key])) {
|
||||
$form['settings'][$key] = $translation[$key];
|
||||
}
|
||||
}
|
||||
$form['locale'] = $locale;
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt
|
||||
* Platzhaltern; ohne eigene Liste gilt Englisch.
|
||||
*/
|
||||
public const MESSAGES = [
|
||||
'en' => [
|
||||
'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.',
|
||||
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
|
||||
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.',
|
||||
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.',
|
||||
'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.',
|
||||
'Dieses Feld wird benötigt.' => 'This field is required.',
|
||||
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.',
|
||||
'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.',
|
||||
'Bitte gib eine Zahl an.' => 'Please enter a number.',
|
||||
'Bitte gib höchstens %s an.' => 'Please enter at most %s.',
|
||||
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
|
||||
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
|
||||
],
|
||||
];
|
||||
|
||||
private static function message(?string $locale, string $text, mixed ...$args): string
|
||||
{
|
||||
$template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text);
|
||||
|
||||
return [] === $args ? $template : sprintf($template, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht.
|
||||
*
|
||||
* @return array<string, int> Formular-ID => Anzahl
|
||||
*/
|
||||
public function submissionCounts(): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) {
|
||||
$counts[(string) $row['form_id']] = (int) $row['n'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
|
||||
public function needsScript(array $form): bool
|
||||
{
|
||||
@@ -401,8 +529,12 @@ final class FormRepository
|
||||
*
|
||||
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
|
||||
*/
|
||||
public function check(array $form, array $input): array
|
||||
/**
|
||||
* @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr
|
||||
*/
|
||||
public function check(array $form, array $input, ?string $locale = null): array
|
||||
{
|
||||
$t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args);
|
||||
$values = [];
|
||||
$errors = [];
|
||||
$consent = false;
|
||||
@@ -427,10 +559,10 @@ final class FormRepository
|
||||
if ('checkboxes' === $field['type']) {
|
||||
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
|
||||
if ([] !== array_diff($picked, $field['options'])) {
|
||||
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
|
||||
$errors[$name] = $t('Bitte wähle nur aus den angebotenen Optionen.');
|
||||
$picked = array_values(array_intersect($picked, $field['options']));
|
||||
} elseif ([] === $picked && $field['required']) {
|
||||
$errors[$name] = 'Bitte wähle mindestens eine Option.';
|
||||
$errors[$name] = $t('Bitte wähle mindestens eine Option.');
|
||||
}
|
||||
$chosen[$name] = $picked;
|
||||
$values[$name] = implode(', ', $picked);
|
||||
@@ -439,39 +571,39 @@ final class FormRepository
|
||||
$value = is_string($raw) ? trim($raw) : '';
|
||||
$limit = 'textarea' === $field['type'] ? 5000 : 200;
|
||||
if (mb_strlen($value) > $limit) {
|
||||
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$errors[$name] = $t('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$value = mb_substr($value, 0, $limit);
|
||||
}
|
||||
if ('consent' === $field['type']) {
|
||||
$consent = '' !== $value;
|
||||
if (!$consent) {
|
||||
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||||
$errors[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.');
|
||||
}
|
||||
$values[$name] = $consent ? 'ja' : '';
|
||||
continue;
|
||||
}
|
||||
if ('' === $value) {
|
||||
if ($field['required']) {
|
||||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
|
||||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.');
|
||||
}
|
||||
$values[$name] = '';
|
||||
continue;
|
||||
}
|
||||
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
$errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.');
|
||||
}
|
||||
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
|
||||
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
|
||||
$errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.');
|
||||
}
|
||||
if ('number' === $field['type']) {
|
||||
$zahl = str_replace(',', '.', $value);
|
||||
if (!is_numeric($zahl)) {
|
||||
$errors[$name] = 'Bitte gib eine Zahl an.';
|
||||
$errors[$name] = $t('Bitte gib eine Zahl an.');
|
||||
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
|
||||
$errors[$name] = match (true) {
|
||||
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
|
||||
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
|
||||
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
|
||||
null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])),
|
||||
null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])),
|
||||
default => $t('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
|
||||
};
|
||||
} else {
|
||||
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
|
||||
@@ -480,7 +612,7 @@ final class FormRepository
|
||||
}
|
||||
if (in_array($field['type'], ['select', 'radio'], true)) {
|
||||
if (!in_array($value, $field['options'], true)) {
|
||||
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
|
||||
$errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.');
|
||||
} else {
|
||||
$chosen[$name] = [$value];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user