Browse Source

bug-fix languages

imwald
Silberengel 2 weeks ago
parent
commit
b492222e08
  1. 140
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 170
      src/components/NoteOptions/DesktopMenu.tsx
  3. 67
      src/components/NoteOptions/MobileMenu.tsx
  4. 13
      src/components/NoteOptions/index.tsx
  5. 37
      src/components/NoteOptions/useMenuActions.tsx
  6. 29
      src/components/ReadAloudPlayerModal.tsx
  7. 4
      src/i18n/locales/en.ts
  8. 34
      src/lib/language-display-meta.test.ts
  9. 99
      src/lib/language-display-meta.ts
  10. 21
      src/lib/language-select-option-lines.tsx
  11. 16
      src/lib/languagetool-language-order.test.ts
  12. 47
      src/lib/read-aloud.ts
  13. 58
      src/lib/trinity-languages.test.ts
  14. 158
      src/lib/trinity-languages.ts
  15. 8
      src/pages/secondary/GeneralSettingsPage/index.tsx

140
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
@ -18,11 +19,12 @@ import logger from '@/lib/logger' @@ -18,11 +19,12 @@ import logger from '@/lib/logger'
import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import {
buildLabLanguageToolPreferenceList,
filterTranslateLanguagesWithLanguageToolPairing
} from '@/lib/trinity-languages'
filterTranslateLanguagesWithGrammarCatalog,
translateLanguageOptionMatchesQuery
} from '@/lib/language-display-meta'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { buildLabLanguageToolPreferenceList } from '@/lib/trinity-languages'
import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import {
@ -480,6 +482,37 @@ export default function AdvancedEventLabDialog({ @@ -480,6 +482,37 @@ export default function AdvancedEventLabDialog({
const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle')
const [translateSource, setTranslateSource] = useState('auto')
const [translateTarget, setTranslateTarget] = useState('en')
const [ltLangFilter, setLtLangFilter] = useState('')
const [translateSrcFilter, setTranslateSrcFilter] = useState('')
const [translateTgtFilter, setTranslateTgtFilter] = useState('')
const ltListFiltered = useMemo(() => {
const f = ltList.filter((code) => translateLanguageOptionMatchesQuery(code, ltLangFilter))
return f.length > 0 ? f : ltList
}, [ltList, ltLangFilter])
const translateLangsFilteredSrc = useMemo(() => {
const f = translateLangs.filter((l) => translateLanguageOptionMatchesQuery(l.code, translateSrcFilter))
return f.length > 0 ? f : translateLangs
}, [translateLangs, translateSrcFilter])
const translateLangsFilteredTgt = useMemo(() => {
const f = translateLangs.filter((l) => translateLanguageOptionMatchesQuery(l.code, translateTgtFilter))
return f.length > 0 ? f : translateLangs
}, [translateLangs, translateTgtFilter])
const showTranslateSourceAuto = useMemo(() => {
const q = translateSrcFilter.trim().toLowerCase()
if (!q) return true
if (translateSource === 'auto') return true
const autoLabel = t('Advanced lab translation source auto').toLowerCase()
return q.includes('auto') || q.includes('detect') || autoLabel.includes(q)
}, [translateSrcFilter, translateSource, t])
useEffect(() => {
if (!open) {
setLtLangFilter('')
setTranslateSrcFilter('')
setTranslateTgtFilter('')
}
}, [open])
useEffect(() => {
if (!open) return
@ -503,7 +536,7 @@ export default function AdvancedEventLabDialog({ @@ -503,7 +536,7 @@ export default function AdvancedEventLabDialog({
void fetchTranslateLanguages()
.then((list) => {
if (cancelled) return
const filtered = filterTranslateLanguagesWithLanguageToolPairing(list)
const filtered = filterTranslateLanguagesWithGrammarCatalog(list)
if (!filtered.length) {
setTranslateLangs([])
setTranslateLoad('empty')
@ -795,12 +828,31 @@ export default function AdvancedEventLabDialog({ @@ -795,12 +828,31 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="lt-lang" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
{ltList.map((code) => (
<SelectItem key={code} value={code}>
<LanguageSelectOptionLines tag={code} />
</SelectItem>
))}
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0">
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={ltLangFilter}
onChange={(e) => setLtLangFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="max-h-52 overflow-y-auto py-1">
{ltListFiltered.map((code) => (
<SelectItem
key={code}
value={code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
@ -831,13 +883,34 @@ export default function AdvancedEventLabDialog({ @@ -831,13 +883,34 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="tr-src" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
{translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}>
<LanguageSelectOptionLines tag={l.code} />
</SelectItem>
))}
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0">
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={translateSrcFilter}
onChange={(e) => setTranslateSrcFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="max-h-52 overflow-y-auto py-1">
{showTranslateSourceAuto ? (
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
) : null}
{translateLangsFilteredSrc.map((l) => (
<SelectItem
key={l.code}
value={l.code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={l.code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
@ -859,12 +932,31 @@ export default function AdvancedEventLabDialog({ @@ -859,12 +932,31 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="tr-tgt" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]">
{translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}>
<LanguageSelectOptionLines tag={l.code} />
</SelectItem>
))}
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0">
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={translateTgtFilter}
onChange={(e) => setTranslateTgtFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="max-h-52 overflow-y-auto py-1">
{translateLangsFilteredTgt.map((l) => (
<SelectItem
key={l.code}
value={l.code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={l.code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>

170
src/components/NoteOptions/DesktopMenu.tsx

@ -8,66 +8,148 @@ import { @@ -8,66 +8,148 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { MenuAction } from './useMenuActions'
import { memo } from 'react'
import { MenuAction, SubMenuAction } from './useMenuActions'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface DesktopMenuProps {
menuActions: MenuAction[]
trigger: React.ReactNode
}
const MenuContent = memo(({ menuActions }: { menuActions: MenuAction[] }) => {
return (
<>
{menuActions.map((action, index) => {
const Icon = action.icon
return (
<div key={index}>
{action.separator && index > 0 && <DropdownMenuSeparator />}
{action.subMenu ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={action.className}>
<Icon />
{action.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] overflow-y-auto"
showScrollButtons
>
{action.subMenu.map((subAction, subIndex) => (
<div key={subIndex}>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={subAction.onClick}
className={cn('w-64', subAction.className)}
>
{subAction.label}
</DropdownMenuItem>
</div>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
function filterSubMenuRows(
items: SubMenuAction[],
searchable: boolean | undefined,
query: string
): SubMenuAction[] {
const q = query.trim().toLowerCase()
if (!searchable || !q) return items
return items.filter((s) => !s.filterHaystack || s.filterHaystack.includes(q))
}
const SubMenuPanel = memo(
({
action,
subMenuFilter,
setSubMenuFilter
}: {
action: MenuAction
subMenuFilter: string
setSubMenuFilter: (s: string) => void
}) => {
const { t } = useTranslation()
const Icon = action.icon
const sub = action.subMenu ?? []
const filtered = useMemo(
() => filterSubMenuRows(sub, action.subMenuSearchable, subMenuFilter),
[sub, action.subMenuSearchable, subMenuFilter]
)
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={action.className}>
<Icon />
{action.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-[min(28rem,calc(100vw-2rem))] max-w-[28rem] min-w-[18rem] p-0"
showScrollButtons
>
{action.subMenuSearchable ? (
<div
className="border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={subMenuFilter}
onChange={(e) => setSubMenuFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
) : null}
<div className="max-h-[min(50vh,22rem)] overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t('Language list filter empty')}
</div>
) : (
<DropdownMenuItem onClick={action.onClick} className={action.className}>
<Icon />
{action.label}
</DropdownMenuItem>
filtered.map((subAction, subIndex) => (
<div key={subIndex}>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={subAction.onClick}
className={cn(
'min-w-0 max-w-none whitespace-normal',
subAction.className
)}
>
{subAction.label}
</DropdownMenuItem>
</div>
))
)}
</div>
)
})}
</>
)
})
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
)
SubMenuPanel.displayName = 'SubMenuPanel'
const MenuContent = memo(
({
menuActions,
subMenuFilter,
setSubMenuFilter
}: {
menuActions: MenuAction[]
subMenuFilter: string
setSubMenuFilter: (s: string) => void
}) => {
return (
<>
{menuActions.map((action, index) => {
const Icon = action.icon
return (
<div key={index}>
{action.separator && index > 0 && <DropdownMenuSeparator />}
{action.subMenu ? (
<SubMenuPanel
action={action}
subMenuFilter={subMenuFilter}
setSubMenuFilter={setSubMenuFilter}
/>
) : (
<DropdownMenuItem onClick={action.onClick} className={action.className}>
<Icon />
{action.label}
</DropdownMenuItem>
)}
</div>
)
})}
</>
)
}
)
MenuContent.displayName = 'MenuContent'
export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) {
const [subMenuFilter, setSubMenuFilter] = useState('')
return (
<DropdownMenu>
<DropdownMenu onOpenChange={(open) => !open && setSubMenuFilter('')}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto">
<MenuContent menuActions={menuActions} />
<MenuContent
menuActions={menuActions}
subMenuFilter={subMenuFilter}
setSubMenuFilter={setSubMenuFilter}
/>
</DropdownMenuContent>
</DropdownMenu>
)

67
src/components/NoteOptions/MobileMenu.tsx

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react'
import { MenuAction, SubMenuAction } from './useMenuActions'
import { useMemo, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface MobileMenuProps {
menuActions: MenuAction[]
@ -11,10 +15,21 @@ interface MobileMenuProps { @@ -11,10 +15,21 @@ interface MobileMenuProps {
showSubMenu: boolean
activeSubMenu: SubMenuAction[]
subMenuTitle: string
subMenuSearchable: boolean
closeDrawer: () => void
goBackToMainMenu: () => void
}
function filterSubMenuRows(
items: SubMenuAction[],
searchable: boolean,
query: string
): SubMenuAction[] {
const q = query.trim().toLowerCase()
if (!searchable || !q) return items
return items.filter((s) => !s.filterHaystack || s.filterHaystack.includes(q))
}
export function MobileMenu({
menuActions,
trigger,
@ -23,9 +38,20 @@ export function MobileMenu({ @@ -23,9 +38,20 @@ export function MobileMenu({
showSubMenu,
activeSubMenu,
subMenuTitle,
subMenuSearchable,
closeDrawer,
goBackToMainMenu
}: MobileMenuProps) {
const { t } = useTranslation()
const [subMenuFilter, setSubMenuFilter] = useState('')
useEffect(() => {
if (!showSubMenu) setSubMenuFilter('')
}, [showSubMenu, activeSubMenu])
const filteredSubMenu = useMemo(
() => filterSubMenuRows(activeSubMenu, subMenuSearchable, subMenuFilter),
[activeSubMenu, subMenuSearchable, subMenuFilter]
)
return (
<>
{trigger}
@ -62,16 +88,37 @@ export function MobileMenu({ @@ -62,16 +88,37 @@ export function MobileMenu({
{subMenuTitle}
</Button>
<div className="border-t border-border mb-2" />
{activeSubMenu.map((subAction, index) => (
<Button
key={index}
onClick={subAction.onClick}
className={`w-full p-6 justify-start text-lg gap-4 ${subAction.className || ''}`}
variant="ghost"
>
{subAction.label}
</Button>
))}
{subMenuSearchable ? (
<div className="px-3 pb-2">
<Input
type="search"
value={subMenuFilter}
onChange={(e) => setSubMenuFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-10"
aria-label={t('Language list filter placeholder')}
/>
</div>
) : null}
{filteredSubMenu.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('Language list filter empty')}
</p>
) : (
filteredSubMenu.map((subAction, index) => (
<Button
key={index}
onClick={subAction.onClick}
className={cn(
'w-full justify-start gap-2 px-4 py-3 h-auto min-h-0 text-left whitespace-normal',
subAction.className
)}
variant="ghost"
>
{subAction.label}
</Button>
))
)}
</>
)}
</div>

13
src/components/NoteOptions/index.tsx

@ -7,7 +7,7 @@ import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEven @@ -7,7 +7,7 @@ import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEven
import { MobileMenu } from './MobileMenu'
import RawEventDialog from './RawEventDialog'
import ReportDialog from './ReportDialog'
import { SubMenuAction, useMenuActions } from './useMenuActions'
import { SubMenuAction, useMenuActions, type ShowSubMenuOptions } from './useMenuActions'
import PostEditor from '../PostEditor'
import type { HighlightData } from '../PostEditor/HighlightEditor'
@ -47,19 +47,27 @@ export default function NoteOptions({ @@ -47,19 +47,27 @@ export default function NoteOptions({
const [showSubMenu, setShowSubMenu] = useState(false)
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
const [subMenuTitle, setSubMenuTitle] = useState('')
const [subMenuSearchable, setSubMenuSearchable] = useState(false)
const closeDrawer = () => {
setIsDrawerOpen(false)
setShowSubMenu(false)
setSubMenuSearchable(false)
}
const goBackToMainMenu = () => {
setShowSubMenu(false)
setSubMenuSearchable(false)
}
const showSubMenuActions = (subMenu: SubMenuAction[], title: string) => {
const showSubMenuActions = (
subMenu: SubMenuAction[],
title: string,
options?: ShowSubMenuOptions
) => {
setActiveSubMenu(subMenu)
setSubMenuTitle(title)
setSubMenuSearchable(Boolean(options?.subMenuSearchable))
setShowSubMenu(true)
}
@ -101,6 +109,7 @@ export default function NoteOptions({ @@ -101,6 +109,7 @@ export default function NoteOptions({
showSubMenu={showSubMenu}
activeSubMenu={activeSubMenu}
subMenuTitle={subMenuTitle}
subMenuSearchable={subMenuSearchable}
closeDrawer={closeDrawer}
goBackToMainMenu={goBackToMainMenu}
/>

37
src/components/NoteOptions/useMenuActions.tsx

@ -67,9 +67,14 @@ import { @@ -67,9 +67,14 @@ import {
isTranslateConfigured,
type TranslateLanguageOption
} from '@/lib/translate-client'
import { languageSelectSingleLine } from '@/lib/language-display-meta'
import {
filterTranslateLanguagesWithGrammarCatalog,
languageSelectSingleLine,
TRANSLATE_LANGUAGE_MENU_ITEM_CLASS
} from '@/lib/language-display-meta'
const EMPTY_TRANSLATE_MENU: readonly TranslateLanguageOption[] = []
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages'
import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -83,6 +88,8 @@ export interface SubMenuAction { @@ -83,6 +88,8 @@ export interface SubMenuAction {
onClick: () => void
className?: string
separator?: boolean
/** Lowercase haystack for submenu filter when the parent sets {@link MenuAction.subMenuSearchable}. */
filterHaystack?: string
}
export interface MenuAction {
@ -92,12 +99,16 @@ export interface MenuAction { @@ -92,12 +99,16 @@ export interface MenuAction {
className?: string
separator?: boolean
subMenu?: SubMenuAction[]
/** Renders a filter field above submenu rows (e.g. translate targets). */
subMenuSearchable?: boolean
}
export type ShowSubMenuOptions = { subMenuSearchable?: boolean }
interface UseMenuActionsProps {
event: Event
closeDrawer: () => void
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
showSubMenuActions: (subMenu: SubMenuAction[], title: string, options?: ShowSubMenuOptions) => void
setIsRawEventDialogOpen: (open: boolean) => void
setIsReportDialogOpen: (open: boolean) => void
isSmallScreen: boolean
@ -168,16 +179,17 @@ export function useMenuActions({ @@ -168,16 +179,17 @@ export function useMenuActions({
() => getNoteTranslation(event.id)
)
const [translateMenuOptions, setTranslateMenuOptions] = useState<TranslateLanguageOption[]>([])
const [translateMenuOptions, setTranslateMenuOptions] =
useState<readonly TranslateLanguageOption[]>(EMPTY_TRANSLATE_MENU)
useEffect(() => {
if (!isTranslateConfigured()) {
setTranslateMenuOptions([])
setTranslateMenuOptions(EMPTY_TRANSLATE_MENU)
return
}
let cancelled = false
void fetchTranslateLanguages().then((list) => {
if (cancelled) return
setTranslateMenuOptions(filterTranslateLanguagesWithLanguageToolPairing(list))
setTranslateMenuOptions(filterTranslateLanguagesWithGrammarCatalog(list))
})
return () => {
cancelled = true
@ -860,6 +872,7 @@ export function useMenuActions({ @@ -860,6 +872,7 @@ export function useMenuActions({
? [
{
label: t('Show original text'),
filterHaystack: `${t('Show original text')} show original`.toLowerCase(),
onClick: () => {
closeDrawer()
clearNoteTranslation(event.id)
@ -868,7 +881,9 @@ export function useMenuActions({ @@ -868,7 +881,9 @@ export function useMenuActions({
},
...translateMenuOptions.map(
(opt, i): SubMenuAction => ({
label: <LanguageSelectOptionLines tag={opt.code} compact />,
label: <LanguageSelectOptionLines tag={opt.code} compact className="w-full" />,
filterHaystack: `${opt.code} ${languageSelectSingleLine(opt.code)}`.toLowerCase(),
className: TRANSLATE_LANGUAGE_MENU_ITEM_CLASS,
separator: i === 0,
onClick: () => {
closeDrawer()
@ -941,9 +956,13 @@ export function useMenuActions({ @@ -941,9 +956,13 @@ export function useMenuActions({
icon: Languages,
label: t('Translate note'),
onClick: isSmallScreen
? () => showSubMenuActions(translateTargetSubmenu, t('Translate note'))
? () =>
showSubMenuActions(translateTargetSubmenu, t('Translate note'), {
subMenuSearchable: true
})
: undefined,
subMenu: isSmallScreen ? undefined : translateTargetSubmenu
subMenu: isSmallScreen ? undefined : translateTargetSubmenu,
subMenuSearchable: true
} as MenuAction
]
: []),

29
src/components/ReadAloudPlayerModal.tsx

@ -115,17 +115,32 @@ export default function ReadAloudPlayerModal(): JSX.Element { @@ -115,17 +115,32 @@ export default function ReadAloudPlayerModal(): JSX.Element {
<p className="font-medium text-foreground line-clamp-2">{snap.title}</p>
) : null}
<p className="text-muted-foreground">{phaseLabel(snap, t)}</p>
{snap.piperUsedEnglishVoiceFallback && snap.piperVoiceRequestedLanguageName ? (
{(snap.piperUsedEnglishVoiceFallback || snap.piperUsedRelatedVoiceFallback) &&
snap.piperVoiceRequestedLanguageName ? (
<div
role="status"
className="rounded-md border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-xs text-foreground"
>
<p className="font-medium">{t('Read-aloud Piper English voice fallback title')}</p>
<p className="mt-1 text-muted-foreground">
{t('Read-aloud Piper English voice fallback detail', {
language: snap.piperVoiceRequestedLanguageName
})}
</p>
{snap.piperUsedEnglishVoiceFallback ? (
<>
<p className="font-medium">{t('Read-aloud Piper English voice fallback title')}</p>
<p className="mt-1 text-muted-foreground">
{t('Read-aloud Piper English voice fallback detail', {
language: snap.piperVoiceRequestedLanguageName
})}
</p>
</>
) : (
<>
<p className="font-medium">{t('Read-aloud Piper related voice fallback title')}</p>
<p className="mt-1 text-muted-foreground">
{t('Read-aloud Piper related voice fallback detail', {
language: snap.piperVoiceRequestedLanguageName,
profile: snap.piperVoiceProfileName
})}
</p>
</>
)}
</div>
) : null}
{snap.engine === 'piper' ? (

4
src/i18n/locales/en.ts

@ -865,6 +865,8 @@ export default { @@ -865,6 +865,8 @@ export default {
"Advanced lab translation languages empty": "Translate service returned no languages (check Docker / LibreTranslate).",
"Advanced lab translation languages error": "Could not load languages from the translate service.",
"Advanced lab translation same source target": "Source and target language must differ.",
"Language list filter placeholder": "Filter languages…",
"Language list filter empty": "No matching languages.",
"Show original text": "Show original text",
"Showing original note text": "Showing original text for this note.",
"Translate note": "Translate note",
@ -873,6 +875,8 @@ export default { @@ -873,6 +875,8 @@ export default {
"Note translation failed": "Translation failed: {{message}}",
"Read-aloud Piper English voice fallback title": "English voice in use",
"Read-aloud Piper English voice fallback detail": "Piper has no voice model for {{language}}. This session uses the English Piper voice instead; pronunciation may not match the written text.",
"Read-aloud Piper related voice fallback title": "Approximate Piper voice",
"Read-aloud Piper related voice fallback detail": "Piper has no model for {{language}}. This session uses the {{profile}} Piper voice as an approximation; pronunciation may not match the written text.",
"Advanced lab translate not configured": "Translation URL is not set (VITE_TRANSLATE_URL).",
"Advanced lab translate done": "Translation inserted into the editor.",
"Advanced lab use translation read aloud": "Use body for read-aloud (this note)",

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

@ -1,5 +1,11 @@ @@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { getLanguageDisplayParts, languageSelectSingleLine } from '@/lib/language-display-meta'
import {
filterTranslateLanguagesWithGrammarCatalog,
getLanguageDisplayParts,
languageSelectSingleLine,
ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES,
translateLanguageOptionMatchesQuery
} from '@/lib/language-display-meta'
describe('getLanguageDisplayParts', () => {
it('uses the static map for German', () => {
@ -20,3 +26,29 @@ describe('getLanguageDisplayParts', () => { @@ -20,3 +26,29 @@ describe('getLanguageDisplayParts', () => {
expect(languageSelectSingleLine('tr')).toContain('Türkçe')
})
})
describe('ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES', () => {
it('lists map ∩ LanguageTool with Turkish and a large set', () => {
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).toContain('tr')
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES.length).toBeGreaterThan(40)
})
})
describe('filterTranslateLanguagesWithGrammarCatalog', () => {
it('keeps only API languages that pair with LT, in catalog order', () => {
const out = filterTranslateLanguagesWithGrammarCatalog([
{ code: 'tr', name: 'Turkish' },
{ code: 'zz-fake', name: 'Fake' },
{ code: 'de', name: 'German' }
])
expect(out.map((l) => l.code)).toEqual(['de', 'tr'])
})
})
describe('translateLanguageOptionMatchesQuery', () => {
it('matches code and English name', () => {
expect(translateLanguageOptionMatchesQuery('de', '')).toBe(true)
expect(translateLanguageOptionMatchesQuery('de', 'ger')).toBe(true)
expect(translateLanguageOptionMatchesQuery('de', 'zzz')).toBe(false)
})
})

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

@ -2,10 +2,19 @@ @@ -2,10 +2,19 @@
* 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 every map key that LanguageTool also pairs with
* (deduped per grammar language). Translate menus use {@link filterTranslateLanguagesWithGrammarCatalog}
* on Libre `/languages` so only installed targets are offered.
*
* 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'
/** 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 }> =
@ -283,3 +292,93 @@ export function languageSelectSingleLine(tag: string): string { @@ -283,3 +292,93 @@ 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)
)
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))
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' }
)
})
}
/** 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'

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

@ -9,23 +9,34 @@ type LinesProps = { @@ -9,23 +9,34 @@ type LinesProps = {
className?: string
}
/** Three-line block: code (mono) · English · native — for `SelectItem` / menus. */
/** One line: ISO/BCP47 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)}>
<div
className={cn(
'flex min-w-0 flex-row flex-wrap items-baseline gap-x-2 gap-y-0.5 text-left',
compact && 'max-w-[20rem]',
className
)}
>
<span
className={cn(
'font-mono text-muted-foreground tabular-nums',
'shrink-0 font-mono tabular-nums text-muted-foreground',
compact ? 'text-[10px]' : 'text-xs'
)}
>
{p.codeLabel}
</span>
<span className={cn('font-medium leading-tight', compact ? 'text-xs' : 'text-sm')}>
<span className={cn('min-w-0 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')}>
<span
className={cn(
'min-w-0 leading-tight text-muted-foreground',
compact ? 'text-[10px]' : 'text-xs'
)}
>
{p.nativeName}
</span>
</div>

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

@ -35,16 +35,22 @@ describe('pickLanguageToolCodeForTranslateTarget', () => { @@ -35,16 +35,22 @@ describe('pickLanguageToolCodeForTranslateTarget', () => {
})
describe('buildLabLanguageToolPreferenceList', () => {
it('puts client language first then en-US then LT codes from translate options', () => {
it('only lists LT codes for installed translate targets (no extra UI language)', () => {
const list = buildLabLanguageToolPreferenceList('de', [
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' }
])
expect(list).toEqual(['fr-FR', 'es'])
})
it('prepends UI language and en-US when those targets are installed', () => {
const list = buildLabLanguageToolPreferenceList('de', [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' }
])
expect(list[0]).toBe('de-DE')
expect(list[1]).toBe('en-US')
expect(list.includes('de-DE')).toBe(true)
expect(list.indexOf('en-US')).toBe(1)
expect(list.includes('fr-FR')).toBe(true)
expect(list.includes('es')).toBe(true)
expect(list).toEqual(['de-DE', 'en-US', 'fr-FR'])
})
})

47
src/lib/read-aloud.ts

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n'
import { getNoteTranslation } from '@/lib/note-translation-display'
import { getPiperVoiceForChosenLanguage, isTrinityLanguageCode } from '@/lib/trinity-languages'
import {
getPiperVoiceForChosenLanguage,
isTrinityLanguageCode,
TRINITY_LANGUAGE_DISPLAY_NAMES
} from '@/lib/trinity-languages'
import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import {
buildPiperTtsCacheKey,
@ -71,8 +75,12 @@ export type ReadAloudSnapshot = { @@ -71,8 +75,12 @@ export type ReadAloudSnapshot = {
backend: string
/** Piper has no model for the chosen language; the English Piper voice is used instead. */
piperUsedEnglishVoiceFallback: boolean
/** Display name of the requested language when {@link piperUsedEnglishVoiceFallback} is true. */
/** Piper uses another trinity voice (e.g. Chinese) to approximate the chosen language. */
piperUsedRelatedVoiceFallback: boolean
/** Display name of the requested language when a Piper fallback (English or related) applies. */
piperVoiceRequestedLanguageName: string
/** Autonym of the Piper profile actually used when English or related fallback applies. */
piperVoiceProfileName: string
}
const initialSnapshot: ReadAloudSnapshot = {
@ -97,7 +105,9 @@ const initialSnapshot: ReadAloudSnapshot = { @@ -97,7 +105,9 @@ const initialSnapshot: ReadAloudSnapshot = {
volume: 1,
backend: '',
piperUsedEnglishVoiceFallback: false,
piperVoiceRequestedLanguageName: ''
piperUsedRelatedVoiceFallback: false,
piperVoiceRequestedLanguageName: '',
piperVoiceProfileName: ''
}
let snapshot: ReadAloudSnapshot = { ...initialSnapshot }
@ -648,7 +658,12 @@ async function speakViaWebSpeech( @@ -648,7 +658,12 @@ async function speakViaWebSpeech(
error: null,
...(!options?.fromPiperFallback ? { usedPiperFallback: false, piperFallbackDetail: null } : {}),
...(options?.browserOnlyNoPiper
? { piperUsedEnglishVoiceFallback: false, piperVoiceRequestedLanguageName: '' }
? {
piperUsedEnglishVoiceFallback: false,
piperUsedRelatedVoiceFallback: false,
piperVoiceRequestedLanguageName: '',
piperVoiceProfileName: ''
}
: {}),
...webspeechPiperFields
})
@ -707,14 +722,24 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult> @@ -707,14 +722,24 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
const chosenReadAloudLang: string =
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en')
const { voice: piperVoice, usedEnglishVoiceFallback } =
getPiperVoiceForChosenLanguage(chosenReadAloudLang)
const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback
? (persistedTranslation?.langLabel ??
const {
voice: piperVoice,
usedEnglishVoiceFallback,
usedRelatedVoiceFallback,
piperProfileCode
} = getPiperVoiceForChosenLanguage(chosenReadAloudLang)
const piperNotice =
usedEnglishVoiceFallback || usedRelatedVoiceFallback
? (persistedTranslation?.langLabel ??
(isTrinityLanguageCode(chosenReadAloudLang)
? LocalizedLanguageNames[chosenReadAloudLang]
: chosenReadAloudLang))
: ''
: ''
const piperVoiceRequestedLanguageName = piperNotice
const piperVoiceProfileName =
usedEnglishVoiceFallback || usedRelatedVoiceFallback
? TRINITY_LANGUAGE_DISPLAY_NAMES[piperProfileCode]
: ''
if (READ_ALOUD_TTS_URL) {
stopReadAloudPlayback()
@ -743,7 +768,9 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult> @@ -743,7 +768,9 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
readAloudPiperTryStartedAt: Date.now(),
backend: readAloudEndpointForLog(),
piperUsedEnglishVoiceFallback: usedEnglishVoiceFallback,
piperVoiceRequestedLanguageName
piperUsedRelatedVoiceFallback: usedRelatedVoiceFallback,
piperVoiceRequestedLanguageName,
piperVoiceProfileName
})
await yieldForReadAloudUi()

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

@ -1,21 +1,49 @@ @@ -1,21 +1,49 @@
import { describe, expect, it } from 'vitest'
import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages'
import { getPiperVoiceForChosenLanguage, TRINITY_PIPER_VOICE } 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'])
describe('getPiperVoiceForChosenLanguage', () => {
it('uses native Piper for trinity codes', () => {
const r = getPiperVoiceForChosenLanguage('de')
expect(r.voice).toBe(TRINITY_PIPER_VOICE.de)
expect(r.usedEnglishVoiceFallback).toBe(false)
expect(r.usedRelatedVoiceFallback).toBe(false)
expect(r.piperProfileCode).toBe('de')
})
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'])
it('routes Japanese and Korean to Chinese Piper', () => {
const ja = getPiperVoiceForChosenLanguage('ja')
expect(ja.voice).toBe(TRINITY_PIPER_VOICE.zh)
expect(ja.usedRelatedVoiceFallback).toBe(true)
expect(ja.piperProfileCode).toBe('zh')
const ko = getPiperVoiceForChosenLanguage('ko')
expect(ko.piperProfileCode).toBe('zh')
})
it('routes Ukrainian to Russian Piper', () => {
const r = getPiperVoiceForChosenLanguage('uk')
expect(r.voice).toBe(TRINITY_PIPER_VOICE.ru)
expect(r.usedRelatedVoiceFallback).toBe(true)
expect(r.piperProfileCode).toBe('ru')
})
it('routes Portuguese to Spanish Piper', () => {
const r = getPiperVoiceForChosenLanguage('pt')
expect(r.voice).toBe(TRINITY_PIPER_VOICE.es)
expect(r.usedRelatedVoiceFallback).toBe(true)
})
it('routes Dutch-adjacent tags to German when not trinity native', () => {
const r = getPiperVoiceForChosenLanguage('gsw')
expect(r.voice).toBe(TRINITY_PIPER_VOICE.de)
expect(r.usedRelatedVoiceFallback).toBe(true)
})
it('falls back to English Piper for unmapped languages', () => {
const r = getPiperVoiceForChosenLanguage('ar')
expect(r.voice).toBe(TRINITY_PIPER_VOICE.en)
expect(r.usedEnglishVoiceFallback).toBe(true)
expect(r.usedRelatedVoiceFallback).toBe(false)
expect(r.piperProfileCode).toBe('en')
})
})

158
src/lib/trinity-languages.ts

@ -1,19 +1,17 @@ @@ -1,19 +1,17 @@
/**
* 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.
* Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`.
* Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then
* **related** Piper (e.g. Chinese for Japanese/Korean, Russian for Ukrainian, Spanish for Portuguese),
* then **English** when no heuristic fits.
*
* **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).
* **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`:
* Libre `/languages` LanguageTool pairing (installed translate targets only).
*/
import type { TranslateLanguageOption } from '@/lib/translate-client'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import {
translateCodeHasLanguageToolPairing,
translateTargetToLanguageToolCode
} from '@/lib/languagetool-language-order'
normalizeTranslateLangCode,
type TranslateLanguageOption
} from '@/lib/translate-client'
import { translateTargetToLanguageToolCode } from '@/lib/languagetool-language-order'
export const TRINITY_LANGUAGE_CODES = [
'en',
@ -85,70 +83,132 @@ export function trinityLanguageToolCode(code: string): string { @@ -85,70 +83,132 @@ export function trinityLanguageToolCode(code: string): string {
return translateTargetToLanguageToolCode(code)
}
export type PiperVoiceResolution = {
voice: string
/** True when using `TRINITY_PIPER_VOICE.en` because no related model was chosen. */
usedEnglishVoiceFallback: boolean
/** True when using another trinity voice (e.g. `zh`) to approximate the requested language. */
usedRelatedVoiceFallback: boolean
/** Which trinity Piper profile is actually used (native, related, or `en`). */
piperProfileCode: TrinityLanguageCode
}
/**
* 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`).
* ISO 639-1 (or base BCP47 segment) trinity Piper voice to approximate when we have no native model.
* Keep conservative: same-script / regional neighbors only where it helps more than English.
*/
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' })
)
const RELATED_PIPER_FOR_BASE: Record<string, TrinityLanguageCode> = {
ja: 'zh',
ko: 'zh',
uk: 'ru',
be: 'ru',
bg: 'ru',
mk: 'ru',
sr: 'ru',
bs: 'ru',
kk: 'ru',
ky: 'ru',
mn: 'ru',
tg: 'ru',
sk: 'cs',
sl: 'de',
hr: 'ru',
pt: 'es',
ca: 'es',
gl: 'es',
it: 'es',
ro: 'es',
la: 'es',
sq: 'es',
oc: 'es',
eu: 'es',
da: 'de',
sv: 'de',
no: 'de',
nb: 'de',
nn: 'de',
is: 'de',
fo: 'de',
lb: 'de',
fy: 'de',
gsw: 'de',
nds: 'de',
et: 'de',
lv: 'de',
fi: 'de',
hu: 'de',
el: 'es',
br: 'fr',
mt: 'es'
}
export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): {
voice: string
usedEnglishVoiceFallback: boolean
} {
export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): PiperVoiceResolution {
return {
voice: TRINITY_PIPER_VOICE[lang],
usedEnglishVoiceFallback: false
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: false,
piperProfileCode: lang
}
}
/** 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)
function baseLangTag(raw: string): string {
const n = normalizeTranslateLangCode(raw).toLowerCase().replace(/_/gu, '-')
return n.split(/-/u)[0] ?? n
}
/** Piper voice for read-aloud: native trinity → related trinity → English. */
export function getPiperVoiceForChosenLanguage(rawLang: string): PiperVoiceResolution {
const base = baseLangTag(rawLang)
const full = normalizeTranslateLangCode(rawLang).toLowerCase().replace(/_/gu, '-')
if (isTrinityLanguageCode(base)) {
return getPiperVoiceForTrinityLanguage(base)
}
if (isTrinityLanguageCode(full)) {
return getPiperVoiceForTrinityLanguage(full)
}
const related = RELATED_PIPER_FOR_BASE[full] ?? RELATED_PIPER_FOR_BASE[base] ?? null
if (related) {
return {
voice: TRINITY_PIPER_VOICE[related],
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: true,
piperProfileCode: related
}
}
return {
voice: TRINITY_FALLBACK_ENGLISH_VOICE,
usedEnglishVoiceFallback: lang !== 'en'
usedEnglishVoiceFallback: base !== 'en' && full !== 'en',
usedRelatedVoiceFallback: false,
piperProfileCode: 'en'
}
}
/**
* LanguageTool `language` dropdown for the lab: UI languages LT code, `en-US`, then one entry per
* translate target (from the filtered LibreTranslate list).
* LanguageTool `language` dropdown for the lab: same logical set as the translate targets (Libre
* `/languages` LT), so grammar never offers e.g. Spanish when the translator was not built with
* Spanish. Order: UI languages LT (if installed), `en-US` (if English is installed), then other
* targets in **translate list order** (matches source/target dropdowns).
*/
export function buildLabLanguageToolPreferenceList(
i18nLanguage: string | undefined,
translateLangs: readonly TranslateLanguageOption[]
): string[] {
const allowedLt = new Set(
translateLangs.map((l) => translateTargetToLanguageToolCode(l.code))
)
const ordered: string[] = []
const push = (c: string) => {
if (!ordered.includes(c)) ordered.push(c)
if (!ordered.includes(c) && allowedLt.has(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)
for (const l of translateLangs) {
push(translateTargetToLanguageToolCode(l.code))
}
return ordered
}

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

@ -84,8 +84,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -84,8 +84,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</SelectTrigger>
<SelectContent className="min-w-[var(--radix-select-trigger-width)]">
{SUPPORTED_APP_LANGUAGE_CODES.map((key) => (
<SelectItem key={key} value={key}>
<LanguageSelectOptionLines tag={key} />
<SelectItem
key={key}
value={key}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={key} className="w-full" />
</SelectItem>
))}
</SelectContent>

Loading…
Cancel
Save