Files
deskhub/DeskHub-Webseite/includes/i18n.php
T
2026-10-24 20:14:54 +00:00

335 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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): array
{
static $cache = [];
if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$file = i18n_languages()[$code]['strings'] ?? '';
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict;
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return $cache[$code] = $dict;
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';
$dict['patterns'][] = [$regex, $value];
} else {
$dict['exact'][$norm] = $value;
}
$dict['count']++;
}
return $cache[$code] = $dict;
}
/** 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) . '&amp;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>';
}