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>
62 lines
2.3 KiB
TypeScript
62 lines
2.3 KiB
TypeScript
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());
|
|
}
|
|
}
|