Browse Source

change language scope

imwald
Silberengel 2 weeks ago
parent
commit
2cb2b746d2
  1. 34
      scripts/sync-i18n-locales.ts
  2. 2
      services/piper-tts-proxy/server.ts
  3. 44
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  4. 45
      src/components/NoteOptions/useMenuActions.tsx
  5. 39
      src/i18n/index.ts
  6. 1833
      src/i18n/locales/ar.ts
  7. 2010
      src/i18n/locales/cs.ts
  8. 4087
      src/i18n/locales/de.ts
  9. 4179
      src/i18n/locales/en.ts
  10. 3837
      src/i18n/locales/es.ts
  11. 1837
      src/i18n/locales/fa.ts
  12. 3838
      src/i18n/locales/fr.ts
  13. 1839
      src/i18n/locales/hi.ts
  14. 1842
      src/i18n/locales/it.ts
  15. 1837
      src/i18n/locales/ja.ts
  16. 1835
      src/i18n/locales/ko.ts
  17. 2010
      src/i18n/locales/nl.ts
  18. 3836
      src/i18n/locales/pl.ts
  19. 1839
      src/i18n/locales/pt-BR.ts
  20. 1841
      src/i18n/locales/pt-PT.ts
  21. 3838
      src/i18n/locales/ru.ts
  22. 1832
      src/i18n/locales/th.ts
  23. 2010
      src/i18n/locales/tr.ts
  24. 3873
      src/i18n/locales/zh.ts
  25. 22
      src/lib/language-display-meta.test.ts
  26. 285
      src/lib/language-display-meta.ts
  27. 33
      src/lib/language-select-option-lines.tsx
  28. 33
      src/lib/languagetool-language-order.test.ts
  29. 8
      src/lib/languagetool-language-order.ts
  30. 6
      src/lib/note-translation-display.ts
  31. 32
      src/lib/piper-voice-for-app-language.ts
  32. 9
      src/lib/read-aloud.ts
  33. 15
      src/lib/translate-note-for-menu.ts
  34. 21
      src/lib/trinity-languages.test.ts
  35. 154
      src/lib/trinity-languages.ts
  36. 11
      src/pages/secondary/GeneralSettingsPage/index.tsx
  37. 2
      src/types/index.d.ts

34
scripts/sync-i18n-locales.ts

@ -7,21 +7,15 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import ar from '../src/i18n/locales/ar' import cs from '../src/i18n/locales/cs'
import de from '../src/i18n/locales/de' import de from '../src/i18n/locales/de'
import en from '../src/i18n/locales/en' import en from '../src/i18n/locales/en'
import es from '../src/i18n/locales/es' import es from '../src/i18n/locales/es'
import fa from '../src/i18n/locales/fa'
import fr from '../src/i18n/locales/fr' import fr from '../src/i18n/locales/fr'
import hi from '../src/i18n/locales/hi' import nl from '../src/i18n/locales/nl'
import it from '../src/i18n/locales/it'
import ja from '../src/i18n/locales/ja'
import ko from '../src/i18n/locales/ko'
import pl from '../src/i18n/locales/pl' import pl from '../src/i18n/locales/pl'
import pt_BR from '../src/i18n/locales/pt-BR'
import pt_PT from '../src/i18n/locales/pt-PT'
import ru from '../src/i18n/locales/ru' import ru from '../src/i18n/locales/ru'
import th from '../src/i18n/locales/th' import tr from '../src/i18n/locales/tr'
import zh from '../src/i18n/locales/zh' import zh from '../src/i18n/locales/zh'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@ -41,22 +35,16 @@ function loadOverrides(localeFile: string): Record<string, string> {
} }
const PACKAGES: { file: string; translation: Record<string, string>; header?: string }[] = [ const PACKAGES: { file: string; translation: Record<string, string>; header?: string }[] = [
{ file: 'ar.ts', translation: ar.translation }, { file: 'cs.ts', translation: cs.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'de.ts', translation: de.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, { file: 'de.ts', translation: de.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'en.ts', translation: en.translation }, { file: 'en.ts', translation: en.translation },
{ file: 'es.ts', translation: es.translation }, { file: 'es.ts', translation: es.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'fa.ts', translation: fa.translation }, { file: 'fr.ts', translation: fr.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'fr.ts', translation: fr.translation }, { file: 'nl.ts', translation: nl.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'hi.ts', translation: hi.translation }, { file: 'pl.ts', translation: pl.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'it.ts', translation: it.translation }, { file: 'ru.ts', translation: ru.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'ja.ts', translation: ja.translation }, { file: 'tr.ts', translation: tr.translation, header: '// NOTE: Untranslated strings fall back to English.\n' },
{ file: 'ko.ts', translation: ko.translation }, { file: 'zh.ts', translation: zh.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }
{ file: 'pl.ts', translation: pl.translation },
{ file: 'pt-BR.ts', translation: pt_BR.translation },
{ file: 'pt-PT.ts', translation: pt_PT.translation },
{ file: 'ru.ts', translation: ru.translation },
{ file: 'th.ts', translation: th.translation },
{ file: 'zh.ts', translation: zh.translation }
] ]
function walk(dir: string, acc: string[] = []): string[] { function walk(dir: string, acc: string[] = []): string[] {

2
services/piper-tts-proxy/server.ts

@ -1024,7 +1024,7 @@ function detectLanguage(text: string): string {
* To see available voices, check the piper-data folder or Wyoming server logs. * To see available voices, check the piper-data folder or Wyoming server logs.
*/ */
function getVoiceForLanguage(lang: string): string { function getVoiceForLanguage(lang: string): string {
// Common voice mappings - adjust based on available voices in your piper-data directory // Voice map keys / ids: keep in sync with `src/lib/trinity-languages.ts` (`TRINITY_PIPER_VOICE`).
const voiceMap: Record<string, string> = { const voiceMap: Record<string, string> = {
'en': 'en_US-lessac-medium', // Default English voice 'en': 'en_US-lessac-medium', // Default English voice
'de': 'de_DE-thorsten-medium', // German 'de': 'de_DE-thorsten-medium', // German

44
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -17,10 +17,12 @@ import {
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isLanguageToolConfigured } from '@/lib/languagetool-client' import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter' import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { import {
buildLanguageToolPreferenceList, buildLabLanguageToolPreferenceList,
pickLanguageToolCodeForTranslateTarget filterTranslateLanguagesWithLanguageToolPairing
} from '@/lib/languagetool-language-order' } from '@/lib/trinity-languages'
import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import { import {
@ -467,22 +469,21 @@ export default function AdvancedEventLabDialog({
} }
}, [open, initial, pushLabCheckpoint, bumpUndoUi]) }, [open, initial, pushLabCheckpoint, bumpUndoUi])
const [translateLangs, setTranslateLangs] = useState<TranslateLanguageOption[]>([])
const ltList = useMemo( const ltList = useMemo(
() => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language), () => buildLabLanguageToolPreferenceList(i18nLanguage ?? i18n.language, translateLangs),
[i18nLanguage, i18n.language] [i18nLanguage, i18n.language, translateLangs]
) )
const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US')
const ltLangRef = useRef(ltLang) const ltLangRef = useRef(ltLang)
ltLangRef.current = ltLang ltLangRef.current = ltLang
const [translateLangs, setTranslateLangs] = useState<TranslateLanguageOption[]>([])
const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle') const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle')
const [translateSource, setTranslateSource] = useState('auto') const [translateSource, setTranslateSource] = useState('auto')
const [translateTarget, setTranslateTarget] = useState('en') const [translateTarget, setTranslateTarget] = useState('en')
useEffect(() => { useEffect(() => {
if (open) { if (!open) return
setLtLang(ltList[0] ?? 'en-US') setLtLang((prev) => (ltList.includes(prev) ? prev : ltList[0] ?? 'en-US'))
}
}, [open, ltList]) }, [open, ltList])
useEffect(() => { useEffect(() => {
@ -502,14 +503,15 @@ export default function AdvancedEventLabDialog({
void fetchTranslateLanguages() void fetchTranslateLanguages()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
if (!list.length) { const filtered = filterTranslateLanguagesWithLanguageToolPairing(list)
if (!filtered.length) {
setTranslateLangs([]) setTranslateLangs([])
setTranslateLoad('empty') setTranslateLoad('empty')
return return
} }
setTranslateLangs(list) setTranslateLangs(filtered)
setTranslateSource('auto') setTranslateSource('auto')
const codes = list.map((l) => l.code) const codes = filtered.map((l) => l.code)
const tgt = codes.includes('en') ? 'en' : codes[0]! const tgt = codes.includes('en') ? 'en' : codes[0]!
setTranslateTarget(tgt) setTranslateTarget(tgt)
setTranslateLoad('ready') setTranslateLoad('ready')
@ -790,13 +792,13 @@ export default function AdvancedEventLabDialog({
setLtLang(code) setLtLang(code)
}} }}
> >
<SelectTrigger id="lt-lang" className="w-[220px]"> <SelectTrigger id="lt-lang" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64"> <SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
{ltList.map((code) => ( {ltList.map((code) => (
<SelectItem key={code} value={code}> <SelectItem key={code} value={code}>
{code} <LanguageSelectOptionLines tag={code} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -826,14 +828,14 @@ export default function AdvancedEventLabDialog({
} }
}} }}
> >
<SelectTrigger id="tr-src" className="w-[220px]"> <SelectTrigger id="tr-src" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64"> <SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem> <SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
{translateLangs.map((l) => ( {translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}> <SelectItem key={l.code} value={l.code}>
{l.name} ({l.code}) <LanguageSelectOptionLines tag={l.code} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -854,13 +856,13 @@ export default function AdvancedEventLabDialog({
} }
}} }}
> >
<SelectTrigger id="tr-tgt" className="w-[220px]"> <SelectTrigger id="tr-tgt" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64"> <SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
{translateLangs.map((l) => ( {translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}> <SelectItem key={l.code} value={l.code}>
{l.name} ({l.code}) <LanguageSelectOptionLines tag={l.code} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

45
src/components/NoteOptions/useMenuActions.tsx

@ -62,8 +62,14 @@ import {
eventHasTranslatableTextBody, eventHasTranslatableTextBody,
translateNoteForDisplay translateNoteForDisplay
} from '@/lib/translate-note-for-menu' } from '@/lib/translate-note-for-menu'
import { isTranslateConfigured } from '@/lib/translate-client' import {
import { LocalizedLanguageNames, SUPPORTED_APP_LANGUAGE_CODES, type TLanguage } from '@/i18n' fetchTranslateLanguages,
isTranslateConfigured,
type TranslateLanguageOption
} from '@/lib/translate-client'
import { languageSelectSingleLine } from '@/lib/language-display-meta'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages'
import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react' import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -162,6 +168,22 @@ export function useMenuActions({
() => getNoteTranslation(event.id) () => getNoteTranslation(event.id)
) )
const [translateMenuOptions, setTranslateMenuOptions] = useState<TranslateLanguageOption[]>([])
useEffect(() => {
if (!isTranslateConfigured()) {
setTranslateMenuOptions([])
return
}
let cancelled = false
void fetchTranslateLanguages().then((list) => {
if (cancelled) return
setTranslateMenuOptions(filterTranslateLanguagesWithLanguageToolPairing(list))
})
return () => {
cancelled = true
}
}, [])
// Check if event is pinned // Check if event is pinned
const [isPinned, setIsPinned] = useState(false) const [isPinned, setIsPinned] = useState(false)
@ -831,6 +853,7 @@ export function useMenuActions({
const noteSupportsTranslateMenu = const noteSupportsTranslateMenu =
isTranslateConfigured() && isTranslateConfigured() &&
translateMenuOptions.length > 0 &&
(eventHasTranslatableTextBody(event) || articleHasTranslatableTitle(event)) (eventHasTranslatableTextBody(event) || articleHasTranslatableTitle(event))
const translateTargetSubmenu: SubMenuAction[] = noteSupportsTranslateMenu const translateTargetSubmenu: SubMenuAction[] = noteSupportsTranslateMenu
@ -843,23 +866,26 @@ export function useMenuActions({
toast.success(t('Showing original note text')) toast.success(t('Showing original note text'))
} }
}, },
...SUPPORTED_APP_LANGUAGE_CODES.map( ...translateMenuOptions.map(
(code: TLanguage, i): SubMenuAction => ({ (opt, i): SubMenuAction => ({
label: LocalizedLanguageNames[code], label: <LanguageSelectOptionLines tag={opt.code} compact />,
separator: i === 0, separator: i === 0,
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
void toast.promise( void toast.promise(
translateNoteForDisplay(event, code).then((out) => { translateNoteForDisplay(event, opt.code).then((out) => {
setNoteTranslation(event.id, { setNoteTranslation(event.id, {
lang: code, lang: opt.code,
langLabel: languageSelectSingleLine(opt.code),
content: out.content, content: out.content,
title: out.title title: out.title
}) })
}), }),
{ {
loading: t('Translating note…'), loading: t('Translating note…'),
success: t('Note translated', { language: LocalizedLanguageNames[code] }), success: t('Note translated', {
language: languageSelectSingleLine(opt.code)
}),
error: (err: unknown) => error: (err: unknown) =>
t('Note translation failed', { t('Note translation failed', {
message: err instanceof Error ? err.message : String(err) message: err instanceof Error ? err.message : String(err)
@ -1229,7 +1255,8 @@ export function useMenuActions({
onOpenEditOrClone, onOpenEditOrClone,
canSignEvents, canSignEvents,
profile, profile,
noteTranslationFromMenu noteTranslationFromMenu,
translateMenuOptions
]) ])
return menuActions return menuActions

39
src/i18n/index.ts

@ -2,33 +2,18 @@ import dayjs from 'dayjs'
import i18n, { Resource } from 'i18next' import i18n, { Resource } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector' import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import { TRINITY_LANGUAGE_CODES, TRINITY_LANGUAGE_DISPLAY_NAMES } from '@/lib/trinity-languages'
import en from './locales/en' import en from './locales/en'
/** Display names only — keeps this module small; locale strings load on demand (except English). */ /** App UI locales with full bundles (see `trinity-languages.ts`); translate menus use Libre+LT from the API. */
const LANGUAGE_META = { const LANGUAGE_META = TRINITY_LANGUAGE_DISPLAY_NAMES
ar: 'العربية',
de: 'Deutsch',
en: 'English',
es: 'Español',
fa: 'فارسی',
fr: 'Français',
hi: 'हि',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
pl: 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
ru: 'Русский',
th: 'ไทย',
zh: '简体中文'
} as const
export type TLanguage = keyof typeof LANGUAGE_META export type TLanguage = keyof typeof LANGUAGE_META
export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGUAGE_META } export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGUAGE_META }
const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[] /** Same codes as {@link TRINITY_LANGUAGE_CODES} — stable order for every language dropdown. */
const supportedLanguages = [...TRINITY_LANGUAGE_CODES] as TLanguage[]
/** App UI languages (same set used for “Translate to …” in note menus). */ /** App UI languages (same set used for “Translate to …” in note menus). */
export const SUPPORTED_APP_LANGUAGE_CODES: readonly TLanguage[] = supportedLanguages export const SUPPORTED_APP_LANGUAGE_CODES: readonly TLanguage[] = supportedLanguages
@ -82,25 +67,17 @@ export function initI18n(): Promise<void> {
i18n.services.formatter?.add('date', (timestamp, lng) => { i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) { switch (lng) {
case 'zh': case 'zh':
case 'ja':
return dayjs(timestamp).format('YYYY年MM月DD日') return dayjs(timestamp).format('YYYY年MM月DD日')
case 'pl': case 'pl':
case 'de': case 'de':
case 'ru': case 'ru':
case 'cs':
return dayjs(timestamp).format('DD.MM.YYYY') return dayjs(timestamp).format('DD.MM.YYYY')
case 'fa':
return dayjs(timestamp).format('YYYY/MM/DD')
case 'it':
case 'es': case 'es':
case 'fr': case 'fr':
case 'pt-BR': case 'nl':
case 'pt-PT': case 'tr':
case 'ar':
case 'hi':
case 'th':
return dayjs(timestamp).format('DD/MM/YYYY') return dayjs(timestamp).format('DD/MM/YYYY')
case 'ko':
return dayjs(timestamp).format('YYYY년 MM월 DD일')
default: default:
return dayjs(timestamp).format('MMM D, YYYY') return dayjs(timestamp).format('MMM D, YYYY')
} }

1833
src/i18n/locales/ar.ts

File diff suppressed because it is too large Load Diff

2010
src/i18n/locales/cs.ts

File diff suppressed because it is too large Load Diff

4087
src/i18n/locales/de.ts

File diff suppressed because it is too large Load Diff

4179
src/i18n/locales/en.ts

File diff suppressed because it is too large Load Diff

3837
src/i18n/locales/es.ts

File diff suppressed because it is too large Load Diff

1837
src/i18n/locales/fa.ts

File diff suppressed because it is too large Load Diff

3838
src/i18n/locales/fr.ts

File diff suppressed because it is too large Load Diff

1839
src/i18n/locales/hi.ts

File diff suppressed because it is too large Load Diff

1842
src/i18n/locales/it.ts

File diff suppressed because it is too large Load Diff

1837
src/i18n/locales/ja.ts

File diff suppressed because it is too large Load Diff

1835
src/i18n/locales/ko.ts

File diff suppressed because it is too large Load Diff

2010
src/i18n/locales/nl.ts

File diff suppressed because it is too large Load Diff

3836
src/i18n/locales/pl.ts

File diff suppressed because it is too large Load Diff

1839
src/i18n/locales/pt-BR.ts

File diff suppressed because it is too large Load Diff

1841
src/i18n/locales/pt-PT.ts

File diff suppressed because it is too large Load Diff

3838
src/i18n/locales/ru.ts

File diff suppressed because it is too large Load Diff

1832
src/i18n/locales/th.ts

File diff suppressed because it is too large Load Diff

2010
src/i18n/locales/tr.ts

File diff suppressed because it is too large Load Diff

3873
src/i18n/locales/zh.ts

File diff suppressed because it is too large Load Diff

22
src/lib/language-display-meta.test.ts

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getLanguageDisplayParts, languageSelectSingleLine } from '@/lib/language-display-meta'
describe('getLanguageDisplayParts', () => {
it('uses the static map for German', () => {
const p = getLanguageDisplayParts('de')
expect(p.codeLabel).toBe('de')
expect(p.englishName).toBe('German')
expect(p.nativeName).toBe('Deutsch')
})
it('uses regional map entries for LanguageTool-style tags', () => {
const p = getLanguageDisplayParts('de-DE')
expect(p.englishName).toBe('German (Germany)')
expect(p.nativeName).toBe('Deutsch (Deutschland)')
})
it('formats a single-line label', () => {
expect(languageSelectSingleLine('tr')).toContain('Turkish')
expect(languageSelectSingleLine('tr')).toContain('Türkçe')
})
})

285
src/lib/language-display-meta.ts

@ -0,0 +1,285 @@
/**
* Canonical ISO-639-style language labels (English + endonym) for selection UI.
* {@link getLanguageDisplayParts} falls back to `Intl.DisplayNames` when a code is missing here.
*
* JSX: {@link LanguageSelectOptionLines} in `language-select-option-lines.tsx` (this file stays `.ts`
* so extensionless imports resolve cleanly under Vite).
*/
import { normalizeTranslateLangCode } from '@/lib/translate-client'
/** 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}`
}

33
src/lib/language-select-option-lines.tsx

@ -0,0 +1,33 @@
import type { ReactElement } from 'react'
import { getLanguageDisplayParts } from '@/lib/language-display-meta'
import { cn } from '@/lib/utils'
type LinesProps = {
tag: string
/** Tighter layout for nested menus */
compact?: boolean
className?: string
}
/** Three-line block: code (mono) · English · native — for `SelectItem` / menus. */
export function LanguageSelectOptionLines({ tag, compact, className }: LinesProps): ReactElement {
const p = getLanguageDisplayParts(tag)
return (
<div className={cn('flex flex-col gap-0.5 text-left', compact && 'max-w-[14rem]', className)}>
<span
className={cn(
'font-mono text-muted-foreground tabular-nums',
compact ? 'text-[10px]' : 'text-xs'
)}
>
{p.codeLabel}
</span>
<span className={cn('font-medium leading-tight', compact ? 'text-xs' : 'text-sm')}>
{p.englishName}
</span>
<span className={cn('text-muted-foreground leading-tight', compact ? 'text-[10px]' : 'text-xs')}>
{p.nativeName}
</span>
</div>
)
}

33
src/lib/languagetool-language-order.test.ts

@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
buildLanguageToolPreferenceList,
pickLanguageToolCodeForTranslateTarget, pickLanguageToolCodeForTranslateTarget,
translateCodeHasLanguageToolPairing,
translateTargetToLanguageToolCode translateTargetToLanguageToolCode
} from '@/lib/languagetool-language-order' } from '@/lib/languagetool-language-order'
import { buildLabLanguageToolPreferenceList } from '@/lib/trinity-languages'
describe('translateTargetToLanguageToolCode', () => { describe('translateTargetToLanguageToolCode', () => {
it('maps ISO codes to LT variants', () => { it('maps ISO codes to LT variants', () => {
@ -13,19 +14,37 @@ describe('translateTargetToLanguageToolCode', () => {
}) })
}) })
describe('translateCodeHasLanguageToolPairing', () => {
it('is true for mapped translate codes', () => {
expect(translateCodeHasLanguageToolPairing('tr')).toBe(true)
expect(translateCodeHasLanguageToolPairing('ja')).toBe(true)
})
it('is false for unknown codes', () => {
expect(translateCodeHasLanguageToolPairing('zz')).toBe(false)
})
})
describe('pickLanguageToolCodeForTranslateTarget', () => { describe('pickLanguageToolCodeForTranslateTarget', () => {
it('returns mapped code when it appears in ltList', () => { it('returns mapped code when it appears in lab LT list', () => {
const lt = buildLanguageToolPreferenceList('en') const lt = buildLabLanguageToolPreferenceList('en', [
expect(pickLanguageToolCodeForTranslateTarget('ja', lt)).toBe('ja-JP') { code: 'de', name: 'German' },
{ code: 'fr', name: 'French' }
])
expect(pickLanguageToolCodeForTranslateTarget('de', lt)).toBe('de-DE')
}) })
}) })
describe('buildLanguageToolPreferenceList', () => { describe('buildLabLanguageToolPreferenceList', () => {
it('puts client language first then en-US then de-DE', () => { it('puts client language first then en-US then LT codes from translate options', () => {
const list = buildLanguageToolPreferenceList('de') const list = buildLabLanguageToolPreferenceList('de', [
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' }
])
expect(list[0]).toBe('de-DE') expect(list[0]).toBe('de-DE')
expect(list[1]).toBe('en-US') expect(list[1]).toBe('en-US')
expect(list.includes('de-DE')).toBe(true) expect(list.includes('de-DE')).toBe(true)
expect(list.indexOf('en-US')).toBe(1) expect(list.indexOf('en-US')).toBe(1)
expect(list.includes('fr-FR')).toBe(true)
expect(list.includes('es')).toBe(true)
}) })
}) })

8
src/lib/languagetool-language-order.ts

@ -128,6 +128,14 @@ export function translateTargetToLanguageToolCode(translateCode: string): string
return 'en-US' return 'en-US'
} }
/** True when this LibreTranslate-style code has an explicit `LT_ALIASES` mapping (not unknown→`en-US`). */
export function translateCodeHasLanguageToolPairing(translateCode: string): boolean {
const n = normalizeTranslateLangCode(translateCode).toLowerCase().replace(/_/gu, '-')
const base = n.split(/-/u)[0] ?? n
return Object.prototype.hasOwnProperty.call(LT_ALIASES, n) ||
Object.prototype.hasOwnProperty.call(LT_ALIASES, base)
}
/** Prefer a code present in `ltList` (lab grammar dropdown) when possible. */ /** Prefer a code present in `ltList` (lab grammar dropdown) when possible. */
export function pickLanguageToolCodeForTranslateTarget( export function pickLanguageToolCodeForTranslateTarget(
translateCode: string, translateCode: string,

6
src/lib/note-translation-display.ts

@ -1,9 +1,11 @@
import type { TLanguage } from '@/i18n'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
export type NoteTranslationEntry = { export type NoteTranslationEntry = {
lang: TLanguage /** LibreTranslate `target` code (from `/languages`). */
lang: string
/** Human label from the translate service (read-aloud fallback when not an app UI locale). */
langLabel?: string
content: string content: string
/** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */ /** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */
title?: string title?: string

32
src/lib/piper-voice-for-app-language.ts

@ -1,32 +0,0 @@
import type { TLanguage } from '@/i18n'
/**
* Piper voice ids aligned with the default Wyoming / `piper-tts-proxy` stock models
* (see `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`).
* App locales without a dedicated model use {@link PIPER_FALLBACK_ENGLISH_VOICE}.
*/
const PIPER_VOICE_BY_APP_LANGUAGE: Partial<Record<TLanguage, string>> = {
en: 'en_US-lessac-medium',
de: 'de_DE-thorsten-medium',
fr: 'fr_FR-siwis-medium',
es: 'es_ES-davefx-medium',
ru: 'ru_RU-ruslan-medium',
zh: 'zh_CN-huayan-medium',
pl: 'pl_PL-darkman-medium'
}
export const PIPER_FALLBACK_ENGLISH_VOICE = 'en_US-lessac-medium'
export function getPiperVoiceForChosenLanguage(lang: TLanguage): {
voice: string
usedEnglishVoiceFallback: boolean
} {
const v = PIPER_VOICE_BY_APP_LANGUAGE[lang]
if (v) {
return { voice: v, usedEnglishVoiceFallback: false }
}
return {
voice: PIPER_FALLBACK_ENGLISH_VOICE,
usedEnglishVoiceFallback: lang !== 'en'
}
}

9
src/lib/read-aloud.ts

@ -1,7 +1,7 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n' import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n'
import { getNoteTranslation } from '@/lib/note-translation-display' import { getNoteTranslation } from '@/lib/note-translation-display'
import { getPiperVoiceForChosenLanguage } from '@/lib/piper-voice-for-app-language' import { getPiperVoiceForChosenLanguage, isTrinityLanguageCode } from '@/lib/trinity-languages'
import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import { import {
buildPiperTtsCacheKey, buildPiperTtsCacheKey,
@ -705,12 +705,15 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
const title = readAloudTitleFromEvent(event) const title = readAloudTitleFromEvent(event)
const chosenReadAloudLang: TLanguage = const chosenReadAloudLang: string =
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en') persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en')
const { voice: piperVoice, usedEnglishVoiceFallback } = const { voice: piperVoice, usedEnglishVoiceFallback } =
getPiperVoiceForChosenLanguage(chosenReadAloudLang) getPiperVoiceForChosenLanguage(chosenReadAloudLang)
const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback
? LocalizedLanguageNames[chosenReadAloudLang] ? (persistedTranslation?.langLabel ??
(isTrinityLanguageCode(chosenReadAloudLang)
? LocalizedLanguageNames[chosenReadAloudLang]
: chosenReadAloudLang))
: '' : ''
if (READ_ALOUD_TTS_URL) { if (READ_ALOUD_TTS_URL) {

15
src/lib/translate-note-for-menu.ts

@ -5,17 +5,11 @@ import {
type AdvancedLabMarkupMode type AdvancedLabMarkupMode
} from '@/lib/advanced-lab-markup-protect' } from '@/lib/advanced-lab-markup-protect'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import type { TLanguage } from '@/i18n' import { normalizeTranslateLangCode } from '@/lib/translate-client'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
const CHUNK_MAX = 2500 const CHUNK_MAX = 2500
/** Map app UI locale codes to LibreTranslate `target` codes where they differ. */
export function translateTargetFromAppLanguage(code: TLanguage): string {
if (code === 'pt-BR' || code === 'pt-PT') return 'pt'
return code
}
function looksLikeStringifiedJsonObject(content: string): boolean { function looksLikeStringifiedJsonObject(content: string): boolean {
const trimmed = content.trim() const trimmed = content.trim()
if ( if (
@ -74,11 +68,14 @@ async function translateLongProtectedBody(
return blocks.join('\n') return blocks.join('\n')
} }
/**
* @param targetCode LibreTranslate target as returned by `/languages` (e.g. `tr`, `zh-CN`).
*/
export async function translateNoteForDisplay( export async function translateNoteForDisplay(
event: Event, event: Event,
appLang: TLanguage targetCode: string
): Promise<{ content: string; title?: string }> { ): Promise<{ content: string; title?: string }> {
const target = translateTargetFromAppLanguage(appLang) const target = normalizeTranslateLangCode(targetCode)
const markupMode: AdvancedLabMarkupMode = isAsciidocMarkupKind(event.kind) ? 'asciidoc' : 'markdown' const markupMode: AdvancedLabMarkupMode = isAsciidocMarkupKind(event.kind) ? 'asciidoc' : 'markdown'
const meta = getLongFormArticleMetadataFromEvent(event) const meta = getLongFormArticleMetadataFromEvent(event)
const origTitle = meta.title?.trim() const origTitle = meta.title?.trim()

21
src/lib/trinity-languages.test.ts

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages'
describe('filterTranslateLanguagesWithLanguageToolPairing', () => {
it('dedupes by LanguageTool grammar code and prefers shorter API codes', () => {
const list = filterTranslateLanguagesWithLanguageToolPairing([
{ code: 'zh-CN', name: 'Chinese (Simplified)' },
{ code: 'zh', name: 'Chinese' },
{ code: 'de', name: 'German' }
])
expect(list.map((l) => l.code).sort()).toEqual(['de', 'zh'])
})
it('drops codes with no LT pairing', () => {
const list = filterTranslateLanguagesWithLanguageToolPairing([
{ code: 'en', name: 'English' },
{ code: 'zz-fake', name: 'Fake' }
])
expect(list.map((l) => l.code)).toEqual(['en'])
})
})

154
src/lib/trinity-languages.ts

@ -0,0 +1,154 @@
/**
* Piper native voices: codes with an entry in `TRINITY_PIPER_VOICE` match `getVoiceForLanguage` in
* `services/piper-tts-proxy/server.ts`. Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native
* Piper for those codes, **English Piper** for every other translate target.
*
* **Translate UIs** (note menu, Advanced lab) list languages from LibreTranslate `/languages` that also
* have an explicit LanguageTool mapping (`translateCodeHasLanguageToolPairing` in
* `languagetool-language-order.ts`). That avoids offering targets LT cannot pair with, and avoids
* showing Turkish when your LibreTranslate image has no `tr` Argos model (the API would error).
*/
import type { TranslateLanguageOption } from '@/lib/translate-client'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import {
translateCodeHasLanguageToolPairing,
translateTargetToLanguageToolCode
} from '@/lib/languagetool-language-order'
export const TRINITY_LANGUAGE_CODES = [
'en',
'de',
'fr',
'es',
'ru',
'zh',
'pl',
'nl',
'cs',
'tr'
] as const
export type TrinityLanguageCode = (typeof TRINITY_LANGUAGE_CODES)[number]
const TRINITY_SET = new Set<string>(TRINITY_LANGUAGE_CODES)
/** Piper voice ids — same as `services/piper-tts-proxy/server.ts` `voiceMap`. */
export const TRINITY_PIPER_VOICE: Record<TrinityLanguageCode, string> = {
en: 'en_US-lessac-medium',
de: 'de_DE-thorsten-medium',
fr: 'fr_FR-siwis-medium',
es: 'es_ES-davefx-medium',
ru: 'ru_RU-ruslan-medium',
zh: 'zh_CN-huayan-medium',
pl: 'pl_PL-darkman-medium',
nl: 'nl_NL-mls-medium',
cs: 'cs_CZ-jirka-medium',
tr: 'tr_TR-dfki-medium'
}
export const TRINITY_FALLBACK_ENGLISH_VOICE = TRINITY_PIPER_VOICE.en
/** Native autonyms / labels for **app UI** locales (General settings); not the full translate menu. */
export const TRINITY_LANGUAGE_DISPLAY_NAMES: { [K in TrinityLanguageCode]: string } = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
ru: 'Русский',
zh: '简体中文',
pl: 'Polski',
nl: 'Nederlands',
cs: 'Čeština',
tr: 'Türkçe'
}
export function isTrinityLanguageCode(s: string): s is TrinityLanguageCode {
return TRINITY_SET.has(s as TrinityLanguageCode)
}
/** Map browser / i18next tag to a trinity app locale (defaults to `en`). */
export function normalizeBrowserLangToTrinityCode(lng: string): TrinityLanguageCode {
const raw = lng.trim().toLowerCase()
const first = raw.split(/[-_]/u)[0] ?? 'en'
if (isTrinityLanguageCode(first)) return first
if (isTrinityLanguageCode(raw)) return raw as TrinityLanguageCode
return 'en'
}
/** LibreTranslate `target` / `source` code (after normalization). */
export function trinityTranslateTarget(code: string): string {
return normalizeTranslateLangCode(code)
}
/** LanguageTool `language` parameter for grammar. */
export function trinityLanguageToolCode(code: string): string {
return translateTargetToLanguageToolCode(code)
}
/**
* LibreTranslate `/languages` entries that have an explicit LT mapping, deduped by LT grammar code
* (one row per LanguageTool language; prefers shorter API codes like `zh` over `zh-CN`).
*/
export function filterTranslateLanguagesWithLanguageToolPairing(
list: TranslateLanguageOption[]
): TranslateLanguageOption[] {
const withPairing = list.filter((l) => translateCodeHasLanguageToolPairing(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)
}
}
return Array.from(byLt.values()).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
}
export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): {
voice: string
usedEnglishVoiceFallback: boolean
} {
return {
voice: TRINITY_PIPER_VOICE[lang],
usedEnglishVoiceFallback: false
}
}
/** Native Piper when we ship a voice; otherwise English Piper (read-aloud / lab). */
export function getPiperVoiceForChosenLanguage(lang: string): {
voice: string
usedEnglishVoiceFallback: boolean
} {
if (isTrinityLanguageCode(lang)) {
return getPiperVoiceForTrinityLanguage(lang)
}
return {
voice: TRINITY_FALLBACK_ENGLISH_VOICE,
usedEnglishVoiceFallback: lang !== 'en'
}
}
/**
* LanguageTool `language` dropdown for the lab: UI languages LT code, `en-US`, then one entry per
* translate target (from the filtered LibreTranslate list).
*/
export function buildLabLanguageToolPreferenceList(
i18nLanguage: string | undefined,
translateLangs: readonly TranslateLanguageOption[]
): string[] {
const ordered: string[] = []
const push = (c: string) => {
if (!ordered.includes(c)) ordered.push(c)
}
const raw = (i18nLanguage ?? 'en').trim() || 'en'
push(translateTargetToLanguageToolCode(raw))
push('en-US')
const extras = translateLangs.map((l) => translateTargetToLanguageToolCode(l.code))
extras.sort((a, b) => a.localeCompare(b))
for (const c of extras) {
push(c)
}
return ordered
}

11
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -8,7 +8,8 @@ import {
NOTIFICATION_LIST_STYLE, NOTIFICATION_LIST_STYLE,
RANDOM_PUBLISH_RELAY_COUNT RANDOM_PUBLISH_RELAY_COUNT
} from '@/constants' } from '@/constants'
import { changeAppLanguage, LocalizedLanguageNames, TLanguage } from '@/i18n' import { changeAppLanguage, SUPPORTED_APP_LANGUAGE_CODES, TLanguage } from '@/i18n'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { cn, isSupportCheckConnectionType } from '@/lib/utils' import { cn, isSupportCheckConnectionType } from '@/lib/utils'
@ -78,13 +79,13 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
{t('Languages')} {t('Languages')}
</Label> </Label>
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}> <Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
<SelectTrigger id="languages" className="w-48"> <SelectTrigger id="languages" className="min-w-[14rem] w-auto max-w-[min(100vw,22rem)]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="min-w-[var(--radix-select-trigger-width)]">
{Object.entries(LocalizedLanguageNames).map(([key, value]) => ( {SUPPORTED_APP_LANGUAGE_CODES.map((key) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{value} <LanguageSelectOptionLines tag={key} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

2
src/types/index.d.ts vendored

@ -171,8 +171,6 @@ export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
export type TFeedType = 'relays' | 'relay' | 'all-favorites' export type TFeedType = 'relays' | 'relay' | 'all-favorites'
export type TFeedInfo = { feedType: TFeedType; id?: string } export type TFeedInfo = { feedType: TFeedType; id?: string }
export type TLanguage = 'en' | 'zh' | 'pl'
export type TImetaInfo = { export type TImetaInfo = {
url: string url: string
blurHash?: string blurHash?: string

Loading…
Cancel
Save