CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
# Conflicts: # src/app/utils/translation/langUtils.ts
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
// Pure language-code helpers for the on-device translation feature. No DOM / no
|
||
// engine — unit-testable in isolation (langUtils.test.ts).
|
||
|
||
/**
|
||
* Normalize a BCP-47 tag to the base language subtag, lowercased:
|
||
* "en-US" -> "en", "ZH" -> "zh", "pt-BR" -> "pt".
|
||
* Chinese is a common exception where script matters (zh-Hant vs zh-Hans); we
|
||
* keep the script subtag for `zh` so a Traditional/Simplified distinction isn't
|
||
* flattened. Everything else collapses to the primary subtag.
|
||
*/
|
||
export function normalizeLang(code: string | undefined | null): string {
|
||
if (!code) return '';
|
||
const lower = code.trim().toLowerCase();
|
||
if (!lower) return '';
|
||
const parts = lower.split(/[-_]/);
|
||
const base = parts[0];
|
||
if (base === 'zh' && (parts[1] === 'hant' || parts[1] === 'hans')) {
|
||
return `zh-${parts[1]}`;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
/** True when two language tags refer to the same base language. */
|
||
export function sameLanguage(a: string | undefined | null, b: string | undefined | null): boolean {
|
||
const na = normalizeLang(a);
|
||
const nb = normalizeLang(b);
|
||
return na !== '' && na === nb;
|
||
}
|
||
|
||
/**
|
||
* Curated list of target languages offered in the "Translate messages into"
|
||
* setting — the common languages the on-device engine can produce. Codes are the
|
||
* normalized base tags passed to the engine.
|
||
*/
|
||
export const TRANSLATE_TARGET_LANGUAGES: ReadonlyArray<{ code: string; name: string }> = [
|
||
{ code: 'en', name: 'English' },
|
||
{ code: 'es', name: 'Spanish' },
|
||
{ code: 'fr', name: 'French' },
|
||
{ code: 'de', name: 'German' },
|
||
{ code: 'it', name: 'Italian' },
|
||
{ code: 'pt', name: 'Portuguese' },
|
||
{ code: 'nl', name: 'Dutch' },
|
||
{ code: 'pl', name: 'Polish' },
|
||
{ code: 'ru', name: 'Russian' },
|
||
{ code: 'uk', name: 'Ukrainian' },
|
||
{ code: 'tr', name: 'Turkish' },
|
||
{ code: 'ar', name: 'Arabic' },
|
||
{ code: 'he', name: 'Hebrew' },
|
||
{ code: 'hi', name: 'Hindi' },
|
||
{ code: 'bn', name: 'Bengali' },
|
||
{ code: 'ja', name: 'Japanese' },
|
||
{ code: 'ko', name: 'Korean' },
|
||
{ code: 'zh', name: 'Chinese' },
|
||
{ code: 'vi', name: 'Vietnamese' },
|
||
{ code: 'th', name: 'Thai' },
|
||
{ code: 'id', name: 'Indonesian' },
|
||
{ code: 'sv', name: 'Swedish' },
|
||
{ code: 'cs', name: 'Czech' },
|
||
{ code: 'ro', name: 'Romanian' },
|
||
{ code: 'el', name: 'Greek' },
|
||
{ code: 'fa', name: 'Persian' },
|
||
];
|
||
|
||
const CURATED_NAME = new Map(TRANSLATE_TARGET_LANGUAGES.map((l) => [l.code, l.name]));
|
||
|
||
/**
|
||
* Human-readable language name for a code, e.g. "de" -> "German". Prefers
|
||
* `Intl.DisplayNames` (localized) and falls back to the curated map, then the
|
||
* raw code. `uiLocale` picks the display locale for the name.
|
||
*/
|
||
export function languageName(code: string, uiLocale = 'en'): string {
|
||
const norm = normalizeLang(code);
|
||
if (!norm) return code;
|
||
try {
|
||
const dn = new Intl.DisplayNames([uiLocale], { type: 'language' });
|
||
const name = dn.of(norm);
|
||
if (name && name.toLowerCase() !== norm) return name;
|
||
} catch {
|
||
/* Intl.DisplayNames unsupported or bad code — fall through */
|
||
}
|
||
return CURATED_NAME.get(norm) ?? norm;
|
||
}
|
||
|
||
/** Is `code` in the curated target-language list? (validates a persisted setting) */
|
||
export function isSupportedTargetLang(code: string): boolean {
|
||
return CURATED_NAME.has(normalizeLang(code));
|
||
}
|
||
|
||
/**
|
||
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
|
||
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
|
||
* people to "the Lotus desktop app" they're already using.
|
||
*/
|
||
export const translationUnavailableText = (desktopApp: boolean): string =>
|
||
desktopApp
|
||
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
|
||
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';
|
||
|
||
/** Pure: why a manual Translate did nothing (#287). */
|
||
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||
from
|
||
? `This message is already in ${languageName(targetLang) || targetLang}.`
|
||
: "Couldn't tell what language this message is in, so it wasn't translated.";
|