/** * 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 = 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 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() 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() 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'