'); } /** * 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('/(|||<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE); if ($parts === false) return $html; $out = ''; $skip = false; // innerhalb