Internationalization: 7 languages with live switching

Custom signal-based runtime (no i18n dependency): shared src/shared/i18n
dictionaries, ui I18nService.t() reactive by construction so language
switches live, and a backend i18n.ts for native menus + thrown errors.
Every user-facing string keyed across templates, toasts, dialogs, file
kinds, columns, macOS menu/context menus, and errors. Language picker in
Options (auto-detects OS locale, falls back to English); switching
relabels the webview and native chrome instantly. English/Spanish/French/
German fully drafted; Chinese/Japanese/Basque drafted, flagged for native
review. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:52:54 +02:00
co-authored by Claude Fable 5
parent d8cfc54049
commit a0f56822cd
43 changed files with 2755 additions and 322 deletions
+61
View File
@@ -0,0 +1,61 @@
import { Injectable, computed, signal } from '@angular/core';
import { DICTS, type Messages } from '@shared/i18n';
import type { AppSettings, SupportedLocale } from '@shared/types';
import { formatDate, formatSize, formatSpeed } from './format';
type Params = Record<string, string | number>;
function lookup(dict: Messages, key: string): string | Messages | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split('.')) {
if (typeof node !== 'object') return undefined;
node = node[part];
}
return node;
}
function interpolate(template: string, params?: Params): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
/**
* Runtime translator. `t()` reads the `locale` signal, so any template binding
* that calls it re-renders live on a language switch (the same reactive
* function-call tracking the file-list icon classes rely on). Missing keys fall
* back to English, then to the raw key so gaps are visible in dev.
*/
@Injectable({ providedIn: 'root' })
export class I18nService {
readonly locale = signal<SupportedLocale>('en');
private readonly dict = computed(() => DICTS[this.locale()]);
t(key: string, params?: Params): string {
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
// plural map: pick the CLDR category for params.count (fallback to English)
if (value && typeof value === 'object') {
const count = Number(params?.['count'] ?? NaN);
if (Number.isFinite(count)) {
const rule = new Intl.PluralRules(this.locale()).select(count);
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
value = typeof picked === 'string' ? picked : undefined;
} else {
value = undefined;
}
}
return typeof value === 'string' ? interpolate(value, params) : key;
}
// Locale-reactive formatting (live-updates the file list on switch).
date(epochMs: number): string {
return formatDate(epochMs, this.locale());
}
size(bytes: number): string {
return formatSize(bytes, this.locale());
}
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
return formatSpeed(bytesPerSec, unit, this.locale());
}
}