9fe0084874
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
344 lines
8.7 KiB
TypeScript
344 lines
8.7 KiB
TypeScript
import * as bcp47Match from 'bcp-47-match'
|
|
import lande from 'lande'
|
|
|
|
import {hasProp} from '#/lib/type-guards'
|
|
import {app} from '#/lexicons'
|
|
import * as bsky from '#/types/bsky'
|
|
import {
|
|
AppLanguage,
|
|
type Language,
|
|
LANGUAGES_MAP_CODE2,
|
|
LANGUAGES_MAP_CODE3,
|
|
} from './languages'
|
|
|
|
export function code2ToCode3(lang: string): string {
|
|
if (lang.length === 2) {
|
|
return LANGUAGES_MAP_CODE2[lang]?.code3 || lang
|
|
}
|
|
return lang
|
|
}
|
|
|
|
export function code3ToCode2(lang: string): string {
|
|
if (lang.length === 3) {
|
|
return LANGUAGES_MAP_CODE3[lang]?.code2 || lang
|
|
}
|
|
return lang
|
|
}
|
|
|
|
export function code3ToCode2Strict(lang: string): string | undefined {
|
|
if (lang.length === 3) {
|
|
return LANGUAGES_MAP_CODE3[lang]?.code2
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
const displayNamesCache = new Map<string, Intl.DisplayNames>()
|
|
|
|
function getDisplayNames(appLang: string): Intl.DisplayNames {
|
|
let cached = displayNamesCache.get(appLang)
|
|
if (!cached) {
|
|
cached = new Intl.DisplayNames([appLang], {
|
|
type: 'language',
|
|
fallback: 'none',
|
|
languageDisplay: 'standard',
|
|
})
|
|
displayNamesCache.set(appLang, cached)
|
|
}
|
|
return cached
|
|
}
|
|
|
|
function getLocalizedLanguage(
|
|
langCode: string,
|
|
appLang: string,
|
|
): string | undefined {
|
|
try {
|
|
return getDisplayNames(appLang).of(langCode) || undefined
|
|
} catch (e) {
|
|
// ignore RangeError from Intl.DisplayNames APIs
|
|
if (!(e instanceof RangeError)) {
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getPostLanguageTags(post: app.bsky.feed.defs.PostView) {
|
|
return bsky.isType(app.bsky.feed.post, post.record) &&
|
|
hasProp(post.record, 'langs') &&
|
|
Array.isArray(post.record.langs)
|
|
? post.record.langs
|
|
: []
|
|
}
|
|
|
|
export function languageName(language: Language, appLang: string): string {
|
|
// if Intl.DisplayNames is unavailable on the target, display the English name
|
|
if (!Intl.DisplayNames) {
|
|
return language.name
|
|
}
|
|
|
|
return getLocalizedLanguage(language.code2, appLang) || language.name
|
|
}
|
|
|
|
export function codeToLanguageName(lang2or3: string, appLang: string): string {
|
|
const code2 = code3ToCode2(lang2or3)
|
|
const knownLanguage = LANGUAGES_MAP_CODE2[code2]
|
|
|
|
return knownLanguage ? languageName(knownLanguage, appLang) : code2
|
|
}
|
|
|
|
export function getPostLanguage(
|
|
post: app.bsky.feed.defs.PostView,
|
|
): string | undefined {
|
|
let candidates: string[] = getPostLanguageTags(post)
|
|
let postText: string = ''
|
|
if (hasProp(post.record, 'text') && typeof post.record.text === 'string') {
|
|
postText = post.record.text
|
|
}
|
|
|
|
// if there's only one declared language, use that
|
|
if (candidates.length === 1) {
|
|
return candidates[0]
|
|
}
|
|
|
|
// no text? can't determine
|
|
if (postText.trim().length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
// run the language model
|
|
let langsProbabilityMap = lande(postText)
|
|
|
|
// filter down using declared languages
|
|
if (candidates.length) {
|
|
langsProbabilityMap = langsProbabilityMap.filter(
|
|
([lang, _probability]: [string, number]) =>
|
|
candidates.includes(code3ToCode2(lang)),
|
|
)
|
|
}
|
|
|
|
if (langsProbabilityMap[0]) {
|
|
return code3ToCode2(langsProbabilityMap[0][0])
|
|
}
|
|
}
|
|
|
|
export function isPostInLanguage(
|
|
post: app.bsky.feed.defs.PostView,
|
|
targetLangs: string[],
|
|
): boolean {
|
|
const lang = getPostLanguage(post)
|
|
if (!lang) {
|
|
// the post has no text, so we just say "yes" for now
|
|
return true
|
|
}
|
|
return bcp47Match.basicFilter(lang, targetLangs).length > 0
|
|
}
|
|
|
|
export function getTranslatorLink(
|
|
text: string,
|
|
targetLangCode: string,
|
|
sourceLanguage?: string,
|
|
): string {
|
|
return `https://translate.google.com/?sl=${sourceLanguage ?? 'auto'}&tl=${targetLangCode}&text=${encodeURIComponent(
|
|
text,
|
|
)}`
|
|
}
|
|
|
|
/**
|
|
* Returns a valid `appLanguage` value from an arbitrary string.
|
|
*
|
|
* Context: post-refactor, we populated some user's `appLanguage` setting with
|
|
* `postLanguage`, which can be a comma-separated list of values. This breaks
|
|
* `appLanguage` handling in the app, so we introduced this util to parse out a
|
|
* valid `appLanguage` from the pre-populated `postLanguage` values.
|
|
*
|
|
* The `appLanguage` will continue to be incorrect until the user returns to
|
|
* language settings and selects a new option, at which point we'll re-save
|
|
* their choice, which should then be a valid option. Since we don't know when
|
|
* this will happen, we should leave this here until we feel it's safe to
|
|
* remove, or we re-migrate their storage.
|
|
*/
|
|
export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
|
const langs = appLanguage.split(',').filter(Boolean)
|
|
|
|
for (const lang of langs) {
|
|
switch (fixLegacyLanguageCode(lang)) {
|
|
case 'en':
|
|
return AppLanguage.en
|
|
case 'an':
|
|
return AppLanguage.an
|
|
case 'ast':
|
|
return AppLanguage.ast
|
|
case 'ca':
|
|
return AppLanguage.ca
|
|
case 'cs':
|
|
return AppLanguage.cs
|
|
case 'cy':
|
|
return AppLanguage.cy
|
|
case 'da':
|
|
return AppLanguage.da
|
|
case 'de':
|
|
return AppLanguage.de
|
|
case 'el':
|
|
return AppLanguage.el
|
|
case 'en-GB':
|
|
return AppLanguage.en_GB
|
|
case 'eo':
|
|
return AppLanguage.eo
|
|
case 'es':
|
|
return AppLanguage.es
|
|
case 'eu':
|
|
return AppLanguage.eu
|
|
case 'fi':
|
|
return AppLanguage.fi
|
|
case 'fr':
|
|
return AppLanguage.fr
|
|
case 'fy':
|
|
return AppLanguage.fy
|
|
case 'ga':
|
|
return AppLanguage.ga
|
|
case 'gd':
|
|
return AppLanguage.gd
|
|
case 'gl':
|
|
return AppLanguage.gl
|
|
case 'hi':
|
|
return AppLanguage.hi
|
|
case 'hu':
|
|
return AppLanguage.hu
|
|
case 'ia':
|
|
return AppLanguage.ia
|
|
case 'id':
|
|
return AppLanguage.id
|
|
case 'it':
|
|
return AppLanguage.it
|
|
case 'ja':
|
|
return AppLanguage.ja
|
|
case 'km':
|
|
return AppLanguage.km
|
|
case 'ko':
|
|
return AppLanguage.ko
|
|
case 'ne':
|
|
return AppLanguage.ne
|
|
case 'nl':
|
|
return AppLanguage.nl
|
|
case 'pl':
|
|
return AppLanguage.pl
|
|
case 'pt-BR':
|
|
return AppLanguage.pt_BR
|
|
case 'pt-PT':
|
|
return AppLanguage.pt_PT
|
|
case 'ro':
|
|
return AppLanguage.ro
|
|
case 'ru':
|
|
return AppLanguage.ru
|
|
case 'sv':
|
|
return AppLanguage.sv
|
|
case 'th':
|
|
return AppLanguage.th
|
|
case 'tr':
|
|
return AppLanguage.tr
|
|
case 'uk':
|
|
return AppLanguage.uk
|
|
case 'vi':
|
|
return AppLanguage.vi
|
|
case 'zh-Hans-CN':
|
|
return AppLanguage.zh_CN
|
|
case 'zh-Hant-HK':
|
|
return AppLanguage.zh_HK
|
|
case 'zh-Hant-TW':
|
|
return AppLanguage.zh_TW
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return AppLanguage.en
|
|
}
|
|
|
|
/**
|
|
* Handles legacy migration for Java devices.
|
|
*
|
|
* {@link https://github.com/bluesky-social/social-app/pull/4461}
|
|
* {@link https://xml.coverpages.org/iso639a.html}
|
|
*/
|
|
export function fixLegacyLanguageCode(code: string | null): string | null {
|
|
if (code === 'in') {
|
|
// indonesian
|
|
return 'id'
|
|
}
|
|
if (code === 'iw') {
|
|
// hebrew
|
|
return 'he'
|
|
}
|
|
if (code === 'ji') {
|
|
// yiddish
|
|
return 'yi'
|
|
}
|
|
return code
|
|
}
|
|
|
|
/**
|
|
* Find the first language supported by our translation infra. Values should be
|
|
* in order of preference, and match the values of {@link AppLanguage}.
|
|
*
|
|
* If no match, returns `en`.
|
|
*/
|
|
export function findSupportedAppLanguage(languageTags: (string | undefined)[]) {
|
|
const supported = new Set(Object.values(AppLanguage))
|
|
for (const tag of languageTags) {
|
|
if (!tag) continue
|
|
if (supported.has(tag as AppLanguage)) {
|
|
return tag
|
|
}
|
|
}
|
|
return AppLanguage.en
|
|
}
|
|
|
|
/**
|
|
* Gets region name for a given country code and language.
|
|
*
|
|
* Falls back to English if unavailable/error, and if that fails, returns the country code.
|
|
*
|
|
* Intl.DisplayNames is widely available + has been polyfilled on native
|
|
*/
|
|
export function regionName(countryCode: string, appLang: string): string {
|
|
const translatedName = getLocalizedRegionName(countryCode, appLang)
|
|
|
|
if (translatedName) {
|
|
return translatedName
|
|
}
|
|
|
|
// Fallback: get English name. Needed for i.e. Esperanto
|
|
const englishName = getLocalizedRegionName(countryCode, 'en')
|
|
if (englishName) {
|
|
return englishName
|
|
}
|
|
|
|
// Final fallback: return country code
|
|
return countryCode
|
|
}
|
|
|
|
const regionNamesCache = new Map<string, Intl.DisplayNames>()
|
|
|
|
function getRegionNames(appLang: string): Intl.DisplayNames {
|
|
let cached = regionNamesCache.get(appLang)
|
|
if (!cached) {
|
|
cached = new Intl.DisplayNames([appLang], {
|
|
type: 'region',
|
|
fallback: 'none',
|
|
})
|
|
regionNamesCache.set(appLang, cached)
|
|
}
|
|
return cached
|
|
}
|
|
|
|
function getLocalizedRegionName(
|
|
countryCode: string,
|
|
appLang: string,
|
|
): string | undefined {
|
|
try {
|
|
return getRegionNames(appLang).of(countryCode)
|
|
} catch (err) {
|
|
console.warn('Error getting localized region name:', err)
|
|
return undefined
|
|
}
|
|
}
|