You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

445 lines
20 KiB

/**
* Canonical ISO-639-style language labels (English + endonym) for selection UI.
* {@link getLanguageDisplayParts} falls back to `Intl.DisplayNames` when a code is missing here.
*
* {@link TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS} lists map keys that LanguageTool pairs with, minus a small
* exclude list for locales not in the default editor Argos stack. Translate menus use
* {@link filterTranslateLanguagesWithGrammarCatalog} on Libre `/languages`, then
* {@link expandTranslateOptionsWithEnglishDialects} adds British English (`en-gb`) when plain `en`
* is installed (Libre still uses API code `en`). {@link buildResolvedTranslateMenuLanguageOptions}
* runs that pipeline for **note menus and Advanced Event Lab** so both stay identical.
*
* JSX: {@link LanguageSelectOptionLines} in `language-select-option-lines.tsx` (this file stays `.ts`
* so extensionless imports resolve cleanly under Vite).
*/
import type { TranslateLanguageOption } from '@/lib/translate-client'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import {
translateCodeHasLanguageToolPairing,
translateTargetToLanguageToolCode
} from '@/lib/languagetool-language-order'
/** Bases omitted from translate-target menus (keep aligned with `scripts/libretranslate-lt.default.env`). */
const TRANSLATE_MENU_EXCLUDED_BASE_CODES = new Set(['ja', 'ko', 'sw'])
function isExcludedTranslateMenuLanguageCode(code: string): boolean {
const n = normalizeTranslateLangCode(code).toLowerCase().replace(/_/gu, '-')
const base = (n.split(/-/u)[0] ?? n).toLowerCase()
return TRANSLATE_MENU_EXCLUDED_BASE_CODES.has(base)
}
/** Lowercase keys: ISO 639-1 base, or BCP47 tag for regional overrides. */
export const LANGUAGE_TRIPLE_BY_LOWER_KEY: Record<string, { english: string; native: string }> =
Object.fromEntries(
(
[
['en', 'English', 'English'],
['de', 'German', 'Deutsch'],
['fr', 'French', 'Français'],
['es', 'Spanish', 'español'],
['it', 'Italian', 'italiano'],
['pt', 'Portuguese', 'português'],
['pt-br', 'Portuguese (Brazil)', 'português (Brasil)'],
['pt-pt', 'Portuguese (Portugal)', 'português (Portugal)'],
['pl', 'Polish', 'polski'],
['ru', 'Russian', 'русский'],
['uk', 'Ukrainian', 'українська'],
['nl', 'Dutch', 'Nederlands'],
['sv', 'Swedish', 'svenska'],
['da', 'Danish', 'dansk'],
['no', 'Norwegian', 'norsk'],
['nb', 'Norwegian Bokmål', 'norsk bokmål'],
['nn', 'Norwegian Nynorsk', 'nynorsk'],
['fi', 'Finnish', 'suomi'],
['el', 'Greek', 'Ελληνικά'],
['tr', 'Turkish', 'Türkçe'],
['ar', 'Arabic', 'العربية'],
['he', 'Hebrew', 'עברית'],
['hi', 'Hindi', 'हि'],
['ja', 'Japanese', '日本語'],
['ko', 'Korean', '한국어'],
['zh', 'Chinese', '中文'],
['zh-cn', 'Chinese (Simplified)', '简体中文'],
['zh-tw', 'Chinese (Traditional)', '繁體中文'],
['zh-hans', 'Chinese (Simplified)', '简体中文'],
['zh-hant', 'Chinese (Traditional)', '繁體中文'],
['fa', 'Persian', 'فارسی'],
['th', 'Thai', 'ไทย'],
['vi', 'Vietnamese', 'Tiếng Việt'],
['ro', 'Romanian', 'română'],
['cs', 'Czech', 'čeština'],
['sk', 'Slovak', 'slovenčina'],
['hu', 'Hungarian', 'magyar'],
['sl', 'Slovenian', 'slovenščina'],
['hr', 'Croatian', 'hrvatski'],
['sr', 'Serbian', 'српски'],
['bg', 'Bulgarian', 'български'],
['lt', 'Lithuanian', 'lietuvių'],
['lv', 'Latvian', 'latviešu'],
['et', 'Estonian', 'eesti'],
['ca', 'Catalan', 'català'],
['gl', 'Galician', 'galego'],
['tl', 'Tagalog', 'Tagalog'],
['id', 'Indonesian', 'Bahasa Indonesia'],
['ms', 'Malay', 'Bahasa Melayu'],
['ta', 'Tamil', 'தமி'],
['te', 'Telugu', 'త'],
['mr', 'Marathi', 'मर'],
['bn', 'Bengali', 'ব'],
['gu', 'Gujarati', 'ગજર'],
['kn', 'Kannada', 'ಕನನಡ'],
['ml', 'Malayalam', 'മലയ'],
['pa', 'Punjabi', 'ਪ'],
['or', 'Odia', 'ଓଡିଆ'],
['as', 'Assamese', 'অসম'],
['ne', 'Nepali', 'न'],
['si', 'Sinhala', 'සහල'],
['lo', 'Lao', 'ລາວ'],
['km', 'Khmer', 'ខរ'],
['my', 'Burmese', 'မ'],
['ka', 'Georgian', 'ქართული'],
['hy', 'Armenian', 'հայերեն'],
['az', 'Azerbaijani', 'azərbaycan'],
['kk', 'Kazakh', 'қазақ тілі'],
['mn', 'Mongolian', 'монгол'],
['af', 'Afrikaans', 'Afrikaans'],
['sw', 'Swahili', 'Kiswahili'],
['zu', 'Zulu', 'isiZulu'],
['xh', 'Xhosa', 'isiXhosa'],
['yo', 'Yoruba', 'Yorùbá'],
['ig', 'Igbo', 'Igbo'],
['ha', 'Hausa', 'Hausa'],
['so', 'Somali', 'Soomaali'],
['am', 'Amharic', 'አማርኛ'],
['ti', 'Tigrinya', 'ትግርኛ'],
['om', 'Oromo', 'Afaan Oromoo'],
['sn', 'Shona', 'chiShona'],
['rw', 'Kinyarwanda', 'Kinyarwanda'],
['mg', 'Malagasy', 'Malagasy'],
['ny', 'Chichewa', 'Chichewa'],
['eo', 'Esperanto', 'Esperanto'],
['lb', 'Luxembourgish', 'Lëtzebuergesch'],
['br', 'Breton', 'brezhoneg'],
['cy', 'Welsh', 'Cymraeg'],
['ga', 'Irish', 'Gaeilge'],
['gd', 'Scottish Gaelic', 'Gàidhlig'],
['mt', 'Maltese', 'Malti'],
['is', 'Icelandic', 'íslenska'],
['fo', 'Faroese', 'føroyskt'],
['tk', 'Turkmen', 'Türkmençe'],
['uz', 'Uzbek', 'oʻzbekcha'],
['ky', 'Kyrgyz', 'кыргызча'],
['tg', 'Tajik', 'тоҷикӣ'],
['ps', 'Pashto', 'پښتو'],
['sd', 'Sindhi', 'سنڌي'],
['ur', 'Urdu', 'اردو'],
['ckb', 'Central Kurdish', 'کوردی'],
['ku', 'Kurdish', 'Kurdî'],
['yi', 'Yiddish', 'ייִדיש'],
['jv', 'Javanese', 'Basa Jawa'],
['su', 'Sundanese', 'Basa Sunda'],
['en-us', 'English (United States)', 'English (United States)'],
['en-gb', 'English (United Kingdom)', 'English (United Kingdom)'],
['de-de', 'German (Germany)', 'Deutsch (Deutschland)'],
['de-at', 'German (Austria)', 'Deutsch (Österreich)'],
['de-ch', 'German (Switzerland)', 'Deutsch (Schweiz)'],
['fr-fr', 'French (France)', 'français (France)'],
['fr-ca', 'French (Canada)', 'français (Canada)'],
['es-es', 'Spanish (Spain)', 'español (España)'],
['es-mx', 'Spanish (Mexico)', 'español (México)'],
['it-it', 'Italian (Italy)', 'italiano (Italia)'],
['ru-ru', 'Russian (Russia)', 'русский (Россия)'],
['pl-pl', 'Polish (Poland)', 'polski (Polska)'],
['ja-jp', 'Japanese (Japan)', '日本語 (日本)'],
['ko-kr', 'Korean (South Korea)', '한국어 (대한민국)'],
['cs-cz', 'Czech (Czechia)', 'čeština (Česko)'],
['sk-sk', 'Slovak (Slovakia)', 'slovenčina (Slovensko)'],
['uk-ua', 'Ukrainian (Ukraine)', 'українська (Україна)'],
['da-dk', 'Danish (Denmark)', 'dansk (Danmark)'],
['fi-fi', 'Finnish (Finland)', 'suomi (Suomi)'],
['el-gr', 'Greek (Greece)', 'Ελληνικά (Ελλάδα)'],
['th-th', 'Thai (Thailand)', 'ไทย (ประเทศไทย)'],
['vi-vn', 'Vietnamese (Vietnam)', 'Tiếng Việt (Việt Nam)'],
['ro-ro', 'Romanian (Romania)', 'română (România)'],
['sl-si', 'Slovenian (Slovenia)', 'slovenščina (Slovenija)'],
['hr-hr', 'Croatian (Croatia)', 'hrvatski (Hrvatska)'],
['bg-bg', 'Bulgarian (Bulgaria)', 'български (България)'],
['lt-lt', 'Lithuanian (Lithuania)', 'lietuvių (Lietuva)'],
['lv-lv', 'Latvian (Latvia)', 'latviešu (Latvija)'],
['et-ee', 'Estonian (Estonia)', 'eesti (Eesti)'],
['ca-es', 'Catalan (Spain)', 'català (Espanya)'],
['gl-es', 'Galician (Spain)', 'galego (España)'],
['tl-ph', 'Tagalog (Philippines)', 'Tagalog (Pilipinas)'],
['ms-my', 'Malay (Malaysia)', 'Bahasa Melayu (Malaysia)'],
['ta-in', 'Tamil (India)', 'தமி (இநி)'],
['te-in', 'Telugu (India)', 'త (భరతద)'],
['mr-in', 'Marathi (India)', 'मर (भरत)'],
['bn-bd', 'Bengali (Bangladesh)', 'ব (বশ)'],
['gu-in', 'Gujarati (India)', 'ગજર (ભરત)'],
['kn-in', 'Kannada (India)', 'ಕನನಡ (ಭರತ)'],
['ml-in', 'Malayalam (India)', 'മലയ (ഇനയ)'],
['pa-in', 'Punjabi (India)', 'ਪ (ਭਰਤ)'],
['or-in', 'Odia (India)', 'ଓଡିଆ (ଭରତ)'],
['as-in', 'Assamese (India)', 'অসম (ভৰত)'],
['ne-np', 'Nepali (Nepal)', 'न (नल)'],
['si-lk', 'Sinhala (Sri Lanka)', 'සහල (ශව)'],
['lo-la', 'Lao (Laos)', 'ລາວ (ລາວ)'],
['km-kh', 'Khmer (Cambodia)', 'ខរ (កម)'],
['my-mm', 'Burmese (Myanmar)', 'မ (မ)'],
['ka-ge', 'Georgian (Georgia)', 'ქართული (საქართველო)'],
['hy-am', 'Armenian (Armenia)', 'հայերեն (Հայաստան)'],
['kk-kz', 'Kazakh (Kazakhstan)', 'қазақ тілі (Қазақстан)'],
['mn-mn', 'Mongolian (Mongolia)', 'монгол (Монгол)'],
['so-so', 'Somali (Somalia)', 'Soomaali (Soomaaliya)'],
['om-et', 'Oromo (Ethiopia)', 'Afaan Oromoo (Itoophiyaa)'],
['sn-zw', 'Shona (Zimbabwe)', 'chiShona (Zimbabwe)'],
['mg-mg', 'Malagasy (Madagascar)', 'Malagasy (Madagasikara)'],
['ny-mw', 'Chichewa (Malawi)', 'Chichewa (Malaŵi)'],
['br-fr', 'Breton (France)', 'brezhoneg (Frañs)'],
['cy-gb', 'Welsh (United Kingdom)', 'Cymraeg (Y Deyrnas Unedig)'],
['ga-ie', 'Irish (Ireland)', 'Gaeilge (Éire)'],
['gd-gb', 'Scottish Gaelic (United Kingdom)', 'Gàidhlig (An Rìoghachd Aonaichte)'],
['mt-mt', 'Maltese (Malta)', 'Malti (Malta)'],
['is-is', 'Icelandic (Iceland)', 'íslenska (Ísland)'],
['fo-fo', 'Faroese (Faroe Islands)', 'føroyskt (Føroyar)'],
['tk-tm', 'Turkmen (Turkmenistan)', 'Türkmençe (Türkmenistan)'],
['uz-uz', 'Uzbek (Uzbekistan)', 'oʻzbekcha (Oʻzbekiston)'],
['ky-kg', 'Kyrgyz (Kyrgyzstan)', 'кыргызча (Кыргызстан)'],
['tg-tj', 'Tajik (Tajikistan)', 'тоҷикӣ (Тоҷикистон)'],
['ps-af', 'Pashto (Afghanistan)', 'پښتو (افغانستان)'],
['sd-in', 'Sindhi (India)', 'سنڌي (انڊيا)'],
['ur-pk', 'Urdu (Pakistan)', 'اردو (پاکستان)'],
['ckb-iq', 'Central Kurdish (Iraq)', 'کوردی (عێراق)'],
['kmr-latn', 'Northern Kurdish (Latin)', 'Kurdî (Latînî)'],
['yi-001', 'Yiddish', 'ייִדיש'],
['jv-latn', 'Javanese (Latin)', 'Basa Jawa (Latin)'],
['su-latn', 'Sundanese (Latin)', 'Basa Sunda (Latin)'],
['ar-sa', 'Arabic (Saudi Arabia)', 'العربية (المملكة العربية السعودية)'],
['he-il', 'Hebrew (Israel)', 'עברית (ישראל)'],
['tr-tr', 'Turkish (Türkiye)', 'Türkçe (Türkiye)'],
['nl-nl', 'Dutch (Netherlands)', 'Nederlands (Nederland)'],
['sv-se', 'Swedish (Sweden)', 'svenska (Sverige)'],
['nb-no', 'Norwegian Bokmål (Norway)', 'norsk bokmål (Norge)'],
['eu', 'Basque', 'euskara'],
['sq', 'Albanian', 'shqip'],
['mk', 'Macedonian', 'македонски'],
['bs', 'Bosnian', 'bosanski'],
['me', 'Montenegrin', 'crnogorski'],
['be', 'Belarusian', 'беларуская'],
['la', 'Latin', 'Latina'],
['grc', 'Ancient Greek', 'Ἑλληνικά'],
['haw', 'Hawaiian', 'ʻŌlelo Hawaiʻi'],
['mi', 'Māori', 'te reo Māori'],
['sa', 'Sanskrit', 'सतम'],
['bo', 'Tibetan', 'བད་སད་'],
['ug', 'Uyghur', 'ئۇيغۇرچە'],
['dz', 'Dzongkha', 'རང་ཁ'],
['nn-no', 'Norwegian Nynorsk (Norway)', 'nynorsk (Noreg)']
] as [string, string, string][]
).map(([k, e, n]) => [k, { english: e, native: n }] as const)
) as Record<string, { english: string; native: string }>
function intlEnglish(tag: string): string {
try {
return new Intl.DisplayNames('en', { type: 'language' }).of(tag.replace(/_/gu, '-')) ?? tag
} catch {
return tag
}
}
function intlNative(tag: string): string {
const bcp = tag.replace(/_/gu, '-')
try {
const v = new Intl.DisplayNames([bcp], { type: 'language' }).of(bcp)
if (v) return v
const loc = bcp.split('-')[0] ?? bcp
return new Intl.DisplayNames([loc], { type: 'language' }).of(loc) ?? intlEnglish(tag)
} catch {
return intlEnglish(tag)
}
}
function mapLookupKeys(normalizedLower: string): string[] {
const keys: string[] = [normalizedLower]
const base = normalizedLower.split(/-/u)[0] ?? normalizedLower
if (base !== normalizedLower) keys.push(base)
return keys
}
export type LanguageDisplayParts = {
/** ISO / BCP47 / service tag shown in UI (normalized Libre style or LT tag). */
codeLabel: string
englishName: string
nativeName: string
}
/**
* @param tag LibreTranslate `code`, LanguageTool tag (e.g. `de-DE`), or UI locale (`en`).
*/
export function getLanguageDisplayParts(tag: string): LanguageDisplayParts {
const raw = tag.trim()
if (raw.toLowerCase() === 'auto') {
return { codeLabel: 'auto', englishName: 'Detect language', nativeName: 'Detect language' }
}
const normalized = normalizeTranslateLangCode(raw)
const lower = normalized.toLowerCase().replace(/_/gu, '-')
const codeLabel = lower
for (const k of mapLookupKeys(lower)) {
const hit = LANGUAGE_TRIPLE_BY_LOWER_KEY[k]
if (hit) {
return { codeLabel, englishName: hit.english, nativeName: hit.native }
}
}
return {
codeLabel,
englishName: intlEnglish(lower),
nativeName: intlNative(lower)
}
}
/** One line: `de — German — Deutsch` */
export function languageSelectSingleLine(tag: string): string {
const p = getLanguageDisplayParts(tag)
return `${p.codeLabel}${p.englishName}${p.nativeName}`
}
/**
* One LibreTranslate-style `code` per LanguageTool grammar language: every key in
* {@link LANGUAGE_TRIPLE_BY_LOWER_KEY} that {@link translateCodeHasLanguageToolPairing} accepts,
* deduped by LT target (shortest tag wins), sorted by English name.
*/
export function getOrderedTranslateGrammarLanguageCodes(): readonly string[] {
const candidates = Object.keys(LANGUAGE_TRIPLE_BY_LOWER_KEY).filter(
(k) =>
translateCodeHasLanguageToolPairing(k) && !isExcludedTranslateMenuLanguageCode(k)
)
const byLt = new Map<string, string>()
for (const c of candidates) {
const lt = translateTargetToLanguageToolCode(c)
const prev = byLt.get(lt)
if (!prev || c.length < prev.length) {
byLt.set(lt, c)
}
}
const codes = [...byLt.values()]
codes.sort((a, b) => {
const ea = getLanguageDisplayParts(a).englishName.toLowerCase()
const eb = getLanguageDisplayParts(b).englishName.toLowerCase()
if (ea !== eb) return ea.localeCompare(eb, undefined, { sensitivity: 'base' })
return a.localeCompare(b)
})
return codes
}
export const ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES: readonly string[] =
getOrderedTranslateGrammarLanguageCodes()
/** Same codes as {@link ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES} for LibreTranslate `Select` rows. */
export const TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS: readonly TranslateLanguageOption[] =
ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES.map((code) => ({
code,
name: languageSelectSingleLine(code)
}))
/** Case-insensitive match on code label, English, native, and single-line label. */
export function translateLanguageOptionMatchesQuery(code: string, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const p = getLanguageDisplayParts(code)
const line = languageSelectSingleLine(code).toLowerCase()
return (
p.codeLabel.toLowerCase().includes(q) ||
p.englishName.toLowerCase().includes(q) ||
p.nativeName.toLowerCase().includes(q) ||
line.includes(q)
)
}
/**
* LibreTranslate `/languages` ∩ LanguageTool pairing: only targets the running server advertises
* (one row per LT language, shortest API code), ordered by the display catalog.
*/
export function filterTranslateLanguagesWithGrammarCatalog(
apiList: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] {
const withPairing = apiList.filter(
(l) =>
translateCodeHasLanguageToolPairing(l.code) && !isExcludedTranslateMenuLanguageCode(l.code)
)
const byLt = new Map<string, TranslateLanguageOption>()
for (const l of withPairing) {
const lt = translateTargetToLanguageToolCode(l.code)
const prev = byLt.get(lt)
if (!prev || l.code.trim().length < prev.code.trim().length) {
byLt.set(lt, l)
}
}
const weight = (opt: TranslateLanguageOption): number => {
const lt = translateTargetToLanguageToolCode(opt.code)
const idx = ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES.findIndex(
(c) => translateTargetToLanguageToolCode(c) === lt
)
return idx === -1 ? 9999 : idx
}
return [...byLt.values()].sort((a, b) => {
const wa = weight(a)
const wb = weight(b)
if (wa !== wb) return wa - wb
return getLanguageDisplayParts(a.code).englishName.localeCompare(
getLanguageDisplayParts(b.code).englishName,
undefined,
{ sensitivity: 'base' }
)
})
}
/**
* When LibreTranslate advertises `en` only, still offer US vs British as separate targets: both
* call the API with `en` ({@link translateApiLanguageCode}); grammar and Piper use `en-US` vs `en-GB`.
*/
export function expandTranslateOptionsWithEnglishDialects(
opts: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] {
const normalized = opts.map((o) => normalizeTranslateLangCode(o.code).toLowerCase().replace(/_/gu, '-'))
if (!normalized.includes('en') || normalized.includes('en-gb')) {
return [...opts]
}
const enIdx = opts.findIndex(
(o) => normalizeTranslateLangCode(o.code).toLowerCase().replace(/_/gu, '-') === 'en'
)
if (enIdx === -1) return [...opts]
const relabeled = opts.map((o, i) =>
i === enIdx ? { ...o, name: languageSelectSingleLine('en-us') } : o
)
const withGb: TranslateLanguageOption[] = [
...relabeled.slice(0, enIdx + 1),
{ code: 'en-gb', name: languageSelectSingleLine('en-gb') },
...relabeled.slice(enIdx + 1)
]
return withGb
}
/**
* Resolved translate/grammar target list: Libre `/languages` (or static fallback when empty),
* filter ∩ LanguageTool (with excluded bases removed), then UK/US English expansion.
* Used by note translate submenus and the Advanced Event Lab dialog so menus never diverge.
*/
export function buildResolvedTranslateMenuLanguageOptions(
apiFetchResult: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] {
const base: readonly TranslateLanguageOption[] =
apiFetchResult.length > 0 ? apiFetchResult : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS
const pipeline = (opts: readonly TranslateLanguageOption[]) =>
expandTranslateOptionsWithEnglishDialects(filterTranslateLanguagesWithGrammarCatalog([...opts]))
let out = pipeline(base)
if (out.length === 0) {
out = pipeline(TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS)
}
return out
}
/** Submenu / dropdown row: align label block; content is horizontal (see `LanguageSelectOptionLines`). */
export const TRANSLATE_LANGUAGE_MENU_ITEM_CLASS =
'!items-start h-auto min-h-0 whitespace-normal py-2 w-full text-left'