');
}
/**
* Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete.
* @return array Schlüssel = Sprachcode
*/
function i18n_languages(bool $refresh = false): array
{
static $cache = null;
if ($cache !== null && !$refresh) return $cache;
$langs = ['de' => ['code' => 'de', 'name' => 'Deutsch', 'flag' => i18n_builtin_flag_de(), 'htmlLang' => 'de', 'slug' => '', 'strings' => '']];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return $cache = $langs;
$rows = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $cache = $langs;
}
foreach ($rows as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
if (!is_file($dir . '/plugin.json')) continue;
$raw = json_decode((string) file_get_contents($dir . '/plugin.json'), true);
$l = is_array($raw) && is_array($raw['language'] ?? null) ? $raw['language'] : null;
if (!$l) continue;
[$manifest] = plugin_read_manifest($dir);
if (!$manifest) continue;
$code = strtolower((string) ($l['code'] ?? ''));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) continue;
$flag = '';
$flagFile = (string) ($l['flag'] ?? '');
if ($flagFile !== '' && plugin_package_path_ok($flagFile) && is_file($dir . '/' . $flagFile) && filesize($dir . '/' . $flagFile) <= I18N_FLAG_MAX_BYTES) {
$ext = strtolower(pathinfo($flagFile, PATHINFO_EXTENSION));
$mime = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'][$ext] ?? '';
if ($mime !== '') $flag = 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($dir . '/' . $flagFile));
}
$stringsFile = (string) ($l['strings'] ?? 'strings.json');
if (!plugin_package_path_ok($stringsFile) || strtolower(pathinfo($stringsFile, PATHINFO_EXTENSION)) !== 'json') $stringsFile = '';
$langs[$code] = [
'code' => $code,
'name' => mb_substr(trim((string) ($l['name'] ?? $code)), 0, 40) ?: $code,
'flag' => $flag !== '' ? $flag : ($code === 'de' ? i18n_builtin_flag_de() : ''),
'htmlLang' => preg_match('/^[A-Za-z\-]{2,12}$/', (string) ($l['htmlLang'] ?? '')) ? (string) $l['htmlLang'] : $code,
'slug' => $slug,
'strings' => $stringsFile !== '' ? $dir . '/' . $stringsFile : '',
];
}
return $cache = $langs;
}
/** Standardsprache der Seite (Einstellung), sonst Deutsch. */
function i18n_default(): string
{
$d = strtolower((string) get_setting('default_language', 'de'));
return isset(i18n_languages()[$d]) ? $d : 'de';
}
/** Sprache dieser Anfrage: Cookie, sonst Standardsprache. */
function i18n_current(): string
{
$c = strtolower((string) ($_COOKIE[I18N_COOKIE] ?? ''));
return $c !== '' && isset(i18n_languages()[$c]) ? $c : i18n_default();
}
/**
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array,patterns:list,count:int}
*/
function i18n_dictionary(string $code, bool $reload = false): array
{
static $cache = [];
if ($reload) unset($cache[$code]);
if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$files = [];
// Zuerst die eigenen Übersetzungen der aktiven Plugins (lang/.json), danach das Sprachpaket: Es gewinnt bei gleichem Text,
// damit sich ein Plugin-Text im Sprachpaket verbessern lässt.
foreach (i18n_plugin_string_files($code) as $f) $files[] = $f;
$packFile = i18n_languages()[$code]['strings'] ?? '';
if ($packFile !== '') $files[] = $packFile;
foreach ($files as $file) i18n_load_strings_file($file, $dict);
// Genauere (längere) Muster zuerst: Sonst würde ein allgemeines wie „Ticket #{}“ ein spezielleres wie „Ticket #{} wurde nicht gefunden.“ überdecken
if (count($dict['patterns']) > 1) {
$order = array_keys($dict['patterns']);
usort($order, fn($x, $y) => strlen((string) ($dict['pkeys'][$y] ?? '')) <=> strlen((string) ($dict['pkeys'][$x] ?? '')));
$dict['patterns'] = array_map(fn($i) => $dict['patterns'][$i], $order);
$dict['pkeys'] = array_map(fn($i) => $dict['pkeys'][$i] ?? '', $order);
}
return $cache[$code] = $dict;
}
/**
* Übersetzungsdateien der aktiven Plugins für eine Sprache: /lang/.json (gleiches Format wie strings.json eines Sprachpakets).
* So bringt jedes Plugin seine Texte selbst mit; eine neue Sprache braucht dann nur die Datei lang/.json im Plugin.
* @return array Plugin-Kennung => Dateipfad
*/
function i18n_plugin_string_files(string $code): array
{
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || $code === 'de') return [];
$slugs = [];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return [];
foreach (get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN) as $slug) {
if (plugin_valid_slug((string) $slug)) $slugs[] = (string) $slug;
}
} catch (Throwable $e) {
return [];
}
$out = [];
foreach ($slugs as $slug) {
$file = plugins_dir() . '/' . $slug . '/lang/' . $code . '.json';
if (is_file($file)) $out[$slug] = $file;
}
return $out;
}
/**
* Welche Plugins bringen für welche Sprachen eigene Übersetzungen mit? (Anzeige unter Sprachen)
* @return array> Plugin-Kennung => [Sprachcode => Anzahl Texte]
*/
function i18n_plugin_language_overview(): array
{
$out = [];
try {
$slugs = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return [];
}
foreach ($slugs as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
foreach (glob(plugins_dir() . '/' . $slug . '/lang/*.json') ?: [] as $f) {
$code = strtolower(basename($f, '.json'));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || filesize($f) > I18N_MAX_STRINGS_BYTES) continue;
$data = json_decode((string) file_get_contents($f), true);
$n = 0;
if (is_array($data)) foreach ($data as $k => $v) if (is_string($v) && $v !== '' && (string) $k !== '' && ((string) $k)[0] !== '_') $n++;
$out[$slug][$code] = $n;
}
}
return $out;
}
/** Liest eine strings.json (Schlüssel = deutscher Text) in ein Wörterbuch ein; spätere Dateien überschreiben frühere. */
function i18n_load_strings_file(string $file, array &$dict): void
{
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return;
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return;
foreach ($data as $key => $value) {
$key = (string) $key;
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
$norm = i18n_normalize($key);
if (str_contains($norm, '{}') || str_contains($norm, '{#}')) {
// {} = beliebiger Text, {#} = nur Ziffern
$pieces = preg_split('/(\{#\}|\{\})/', $norm, -1, PREG_SPLIT_DELIM_CAPTURE);
$regex = '/^';
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
$regex .= '$/su';
// gleicher Schlüssel in einer späteren Datei ersetzt das frühere Muster
foreach ($dict['patterns'] as $i => $p) if (($dict['pkeys'][$i] ?? null) === $norm) { unset($dict['patterns'][$i], $dict['pkeys'][$i]); $dict['count']--; }
$dict['patterns'] = array_values($dict['patterns']);
$dict['pkeys'] = array_values($dict['pkeys'] ?? []);
$dict['patterns'][] = [$regex, $value];
$dict['pkeys'][] = $norm;
$dict['count']++;
} else {
if (!isset($dict['exact'][$norm])) $dict['count']++;
$dict['exact'][$norm] = $value;
}
}
}
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
function i18n_normalize(string $text): string
{
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
/** Übersetzt einen (normalisierten) Text; null = keine Übersetzung bekannt. */
function i18n_lookup(string $norm, array $dict): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $value]) {
if (preg_match($regex, $norm, $m)) {
$value = preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1]] ?? '', $value);
$i = 0;
return preg_replace_callback('/\{\}/', function () use ($m, &$i) { $i++; return $m[$i] ?? ''; }, (string) $value);
}
}
return null;
}
/** Texte, die nicht übersetzt werden müssen: Zahlen, Adressen, Zeiten, reine Zeichen. */
function i18n_translatable(string $norm): bool
{
if (mb_strlen($norm) < 2 || mb_strlen($norm) > 400) return false;
if (!preg_match('/\p{L}{2}/u', $norm)) return false;
if (preg_match('#^(https?://|/|[\w.+-]+@[\w-]+\.)#i', $norm)) return false;
return true;
}
/**
* Übersetzt eine HTML-Seite. $seen sammelt (wenn übergeben) die deutschen Texte, für die es keine Übersetzung gab.
* @param array|null $seen
*/
function i18n_translate_html(string $html, string $code, ?array &$seen = null): string
{
$dict = $code === 'de' ? ['exact' => [], 'patterns' => [], 'count' => 0] : i18n_dictionary($code);
$collect = $seen !== null;
if (!$collect && !$dict['count']) return $html;
$note = function (string $norm) use (&$seen, $collect): void {
if ($collect && i18n_translatable($norm)) $seen[$norm] = true;
};
$tr = function (string $norm) use ($dict, $note): ?string {
$t = i18n_lookup($norm, $dict);
if ($t === null) $note($norm);
return $t;
};
// JavaScript: Textliterale, die genau einem Schlüssel entsprechen
$jsLiterals = function (string $js, bool $collectCalls) use ($dict, $note): string {
return (string) preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($m) use ($dict, $note) {
$raw = str_replace(["\\'", '\\"', '\\\\', '\\n'], ["'", '"', '\\', ' '], $m[2]);
$norm = trim((string) preg_replace('/\s+/u', ' ', $raw));
if ($norm === '') return $m[0];
$t = i18n_lookup($norm, $dict);
return $t === null ? $m[0] : json_encode($t, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}, $js);
};
$jsCollect = function (string $js) use ($note, $dict): void {
if (preg_match_all('/(?:confirm|alert|prompt|toast)\(\s*(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', $js, $mm)) {
foreach ($mm[2] as $lit) {
$norm = trim((string) preg_replace('/\s+/u', ' ', str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $lit)));
if (i18n_lookup($norm, $dict) === null) $note($norm); // schon Übersetztes nicht noch einmal sammeln
}
}
};
// Attribute eines Tags
$tag = function (string $t) use ($tr, $dict, $note, $jsCollect): string {
$isButton = (bool) preg_match('/^]*\btype\s*=\s*["\']?(submit|button|reset)\b/i', $t);
return (string) preg_replace_callback('/\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(")([^"]*)"|\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(\')([^\']*)\'/i', function ($m) use ($tr, $dict, $isButton, $jsCollect) {
$name = strtolower($m[1] !== '' ? $m[1] : $m[4]);
$quote = $m[1] !== '' ? $m[2] : $m[5];
$val = $m[1] !== '' ? $m[3] : $m[6];
if ($name === 'value' && !$isButton) return $m[0];
if (str_starts_with($name, 'on')) {
$js = html_entity_decode($val, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$jsCollect($js);
$new = preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($x) use ($dict) {
$raw = str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $x[2]);
$t = i18n_lookup(trim((string) preg_replace('/\s+/u', ' ', $raw)), $dict);
return $t === null ? $x[0] : "'" . addcslashes($t, "'\\") . "'";
}, $js);
return $new === $js ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars((string) $new, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}
$norm = i18n_normalize($val);
$t = $tr($norm);
return $t === null ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars($t, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}, $t);
};
$parts = preg_split('/(|