412 lines
22 KiB
PHP
412 lines
22 KiB
PHP
<?php
|
||
/**
|
||
* includes/i18n.php
|
||
*
|
||
* Mehrsprachigkeit. Der Quelltext von DeskHub ist deutsch; jede weitere Sprache kommt als Plugin ("Sprachpaket"), das nur Daten enthält:
|
||
*
|
||
* plugin.json {"name":"English","version":"1.0.0","entry":"plugin.php","language":{"code":"en","name":"English","flag":"flag.svg","strings":"strings.json","htmlLang":"en"}}
|
||
* plugin.php (leer; wird nur verlangt, weil jedes Plugin eine Startdatei hat)
|
||
* flag.svg Flagge (SVG oder PNG, höchstens 40 KB) – sie erscheint im Sprachwechsler
|
||
* strings.json {"Neues Ticket": "New ticket", "Willkommen, {}": "Welcome, {}", ...}
|
||
*
|
||
* Übersetzt wird die fertige Seite: Textstücke zwischen den HTML-Tags, Attribute wie placeholder/title/alt, Beschriftungen von Knöpfen und
|
||
* Textliteralen in confirm()/alert(). Ein Schlüssel muss genau dem deutschen Text entsprechen (Leerraum zählt nicht). "{}" steht für
|
||
* wechselnde Teile ("{#}" nur für Zahlen) ("Willkommen, {}"); "{1}", "{2}" in der Übersetzung erlauben eine andere Reihenfolge. Was nicht übersetzt ist, bleibt deutsch.
|
||
*
|
||
* Sprache wählen: Cookie "deskhub_lang" (gesetzt über /lang.php, dem Klick auf eine Flagge), sonst die Standardsprache (Einstellung
|
||
* "default_language", sonst Deutsch). Ist Deutsch gewählt und der Sammelmodus aus, läuft nichts davon: die Seiten werden unverändert ausgeliefert.
|
||
*
|
||
* Sammelmodus (Adminportal → Sprachen): merkt sich die deutschen Texte der besuchten Seiten, daraus entsteht eine Vorlage für Übersetzer.
|
||
*/
|
||
|
||
const I18N_COOKIE = 'deskhub_lang';
|
||
const I18N_FLAG_MAX_BYTES = 40960;
|
||
const I18N_MAX_STRINGS_BYTES = 2 * 1024 * 1024;
|
||
|
||
/** Eingebaute Flagge für Deutsch (Quellsprache; sie ist immer verfügbar, auch ohne Plugin). */
|
||
function i18n_builtin_flag_de(): string
|
||
{
|
||
return 'data:image/svg+xml;base64,' . base64_encode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1"/></svg>');
|
||
}
|
||
|
||
/**
|
||
* Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete.
|
||
* @return array<string,array{code:string,name:string,flag:string,htmlLang:string,slug:string,strings:string}> 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<string,string>,patterns:list<array{0:string,1:string}>,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/<code>.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: <Plugin>/lang/<code>.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/<code>.json im Plugin.
|
||
* @return array<string,string> 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<string,array<string,int>> 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<string,bool>|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('/^<input\b[^>]*\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('/(<!--.*?-->|<script\b.*?<\/script>|<style\b.*?<\/style>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||
if ($parts === false) return $html;
|
||
$out = '';
|
||
$skip = false; // innerhalb <textarea> stehen Eingaben, keine Beschriftungen
|
||
foreach ($parts as $i => $part) {
|
||
if ($part === '') continue;
|
||
if ($i % 2 === 0) { // Text
|
||
if ($skip || trim($part) === '') { $out .= $part; continue; }
|
||
if (!preg_match('/^(\s*)(.*?)(\s*)$/su', $part, $m)) { $out .= $part; continue; }
|
||
$t = $tr(i18n_normalize($m[2]));
|
||
$out .= $t === null ? $part : $m[1] . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . $m[3];
|
||
continue;
|
||
}
|
||
if ($part[0] === '<' && $part[1] === '!') { $out .= $part; continue; } // Kommentar / doctype
|
||
if (stripos($part, '<script') === 0) {
|
||
if (preg_match('/^(<script\b[^>]*>)(.*)(<\/script>)$/is', $part, $m) && !preg_match('/\bsrc\s*=/i', $m[1])) {
|
||
if ($collect) $jsCollect($m[2]);
|
||
$out .= $m[1] . ($dict['count'] ? $jsLiterals($m[2], $collect) : $m[2]) . $m[3];
|
||
} else {
|
||
$out .= $part;
|
||
}
|
||
continue;
|
||
}
|
||
if (stripos($part, '<style') === 0) { $out .= $part; continue; }
|
||
if (stripos($part, '<textarea') === 0) $skip = true;
|
||
elseif (stripos($part, '</textarea') === 0) $skip = false;
|
||
if (stripos($part, '<html') === 0 && $code !== 'de') {
|
||
$lang = i18n_languages()[$code]['htmlLang'] ?? $code;
|
||
$part = preg_match('/\blang\s*=/i', $part) ? (string) preg_replace('/\blang\s*=\s*(["\'])[^"\']*\1/i', 'lang="' . $lang . '"', $part, 1) : (string) preg_replace('/^<html/i', '<html lang="' . $lang . '"', $part, 1);
|
||
}
|
||
$out .= $tag($part);
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/** Ausgabe-Filter der Seite (ob_start-Callback): nur HTML-Antworten werden angefasst. */
|
||
function i18n_output_filter(string $buffer, int $phase = 0)
|
||
{
|
||
try {
|
||
foreach (headers_list() as $h) {
|
||
if (stripos($h, 'content-type:') === 0 && stripos($h, 'text/html') === false) return false;
|
||
}
|
||
if (strlen($buffer) < 20 || !preg_match('/<[a-z!]/i', $buffer) || strlen($buffer) > 6 * 1024 * 1024) return false;
|
||
$code = $GLOBALS['__i18n_code'] ?? 'de';
|
||
$seen = null;
|
||
if (get_setting('i18n_collect', '0') === '1') $seen = [];
|
||
$html = i18n_translate_html($buffer, $code, $seen);
|
||
if ($seen) i18n_remember(array_keys($seen));
|
||
return $html;
|
||
} catch (Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Merkt sich unübersetzte Texte (Sammelmodus). Höchstens 6000 Einträge. */
|
||
function i18n_remember(array $texts): void
|
||
{
|
||
$pdo = get_db();
|
||
$texts = array_slice(array_values(array_unique(array_map(fn($t) => mb_substr((string) $t, 0, 400), $texts))), 0, 400);
|
||
if (!$texts) return;
|
||
$hashes = array_map('md5', $texts);
|
||
$in = implode(',', array_fill(0, count($hashes), '?'));
|
||
$have = $pdo->prepare("SELECT text_hash FROM i18n_missing WHERE text_hash IN ($in)");
|
||
$have->execute($hashes);
|
||
$known = array_flip($have->fetchAll(PDO::FETCH_COLUMN));
|
||
$room = 6000 - (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
|
||
$ins = $pdo->prepare('INSERT INTO i18n_missing (text_hash, text, hits, last_seen) VALUES (?,?,1,?)');
|
||
$upd = $pdo->prepare('UPDATE i18n_missing SET hits = hits + 1, last_seen = ? WHERE text_hash = ?');
|
||
foreach ($texts as $k => $t) {
|
||
if (isset($known[$hashes[$k]])) { $upd->execute([db_now(), $hashes[$k]]); continue; }
|
||
if ($room-- <= 0) break;
|
||
try { $ins->execute([$hashes[$k], $t, db_now()]); } catch (Throwable $e) { }
|
||
}
|
||
}
|
||
|
||
/** Startet die Übersetzung dieser Anfrage (wird von bootstrap.php aufgerufen). */
|
||
function i18n_start(): void
|
||
{
|
||
if (PHP_SAPI === 'cli' || defined('DESKHUB_NO_I18N')) return;
|
||
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||
if (str_starts_with($path, '/api/') || str_ends_with($path, '/lang.php')) return;
|
||
$code = i18n_current();
|
||
$GLOBALS['__i18n_code'] = $code;
|
||
if ($code === 'de' && get_setting('i18n_collect', '0') !== '1') return;
|
||
ob_start('i18n_output_filter');
|
||
}
|
||
|
||
/** Pfad für „zurück“ nach dem Sprachwechsel: nur Adressen dieser Seite. */
|
||
function i18n_safe_back(string $back): string
|
||
{
|
||
if ($back === '' || $back[0] !== '/' || str_starts_with($back, '//') || str_contains($back, '\\') || preg_match('/[\r\n\0]/', $back)) return '/';
|
||
return $back;
|
||
}
|
||
|
||
/**
|
||
* Sprachwechsler mit Flaggen. Leer, solange es nur eine Sprache gibt.
|
||
* $variant: "inline" (helle Leiste), "dark" (dunkle Seitenleiste) oder "floating" (feste Position oben rechts, für Anmeldeseiten).
|
||
*/
|
||
function i18n_switcher_html(string $variant = 'inline'): string
|
||
{
|
||
$langs = i18n_languages();
|
||
if (count($langs) < 2) return '';
|
||
$cur = $GLOBALS['__i18n_code'] ?? i18n_current();
|
||
$back = rawurlencode((string) ($_SERVER['REQUEST_URI'] ?? '/'));
|
||
$wrapStyle = 'display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;' . ($variant === 'floating' ? 'position:fixed;bottom:14px;right:14px;z-index:9999;background:rgba(255,255,255,.88);padding:5px 8px;border-radius:20px;box-shadow:0 1px 4px rgba(0,0,0,.18)' : '');
|
||
$html = '<span class="lang-switch" role="group" aria-label="Language" style="' . $wrapStyle . '">';
|
||
foreach ($langs as $code => $l) {
|
||
$on = $code === $cur;
|
||
$inner = $l['flag'] !== ''
|
||
? '<img src="' . htmlspecialchars($l['flag'], ENT_QUOTES) . '" alt="' . htmlspecialchars($l['name'], ENT_QUOTES) . '" width="24" height="16" style="display:block;width:24px;height:16px;object-fit:cover;border-radius:3px">'
|
||
: '<span style="font-size:.75rem;font-weight:700">' . htmlspecialchars(strtoupper($code)) . '</span>';
|
||
$html .= '<a href="/lang.php?l=' . rawurlencode($code) . '&back=' . $back . '" hreflang="' . htmlspecialchars($l['htmlLang'], ENT_QUOTES) . '" title="' . htmlspecialchars($l['name'], ENT_QUOTES) . '"'
|
||
. ' style="display:inline-block;line-height:0;padding:2px;border-radius:5px;text-decoration:none;opacity:' . ($on ? '1' : '.6') . ';outline:' . ($on ? '2px solid ' . ($variant === 'dark' ? '#8bc4c2' : 'var(--primary,#0f6f6a)') : 'none') . ';outline-offset:1px"'
|
||
. ($on ? ' aria-current="true"' : '') . '>' . $inner . '</a>';
|
||
}
|
||
return $html . '</span>';
|
||
}
|