Browse Source

bug-fix languages

imwald
Silberengel 2 weeks ago
parent
commit
b492222e08
  1. 126
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 114
      src/components/NoteOptions/DesktopMenu.tsx
  3. 53
      src/components/NoteOptions/MobileMenu.tsx
  4. 13
      src/components/NoteOptions/index.tsx
  5. 37
      src/components/NoteOptions/useMenuActions.tsx
  6. 17
      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. 43
      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

126
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,11 +19,12 @@ 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 { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { import {
buildLabLanguageToolPreferenceList, filterTranslateLanguagesWithGrammarCatalog,
filterTranslateLanguagesWithLanguageToolPairing translateLanguageOptionMatchesQuery
} from '@/lib/trinity-languages' } 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 { 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 {
@ -480,6 +482,37 @@ export default function AdvancedEventLabDialog({
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')
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(() => { useEffect(() => {
if (!open) return if (!open) return
@ -503,7 +536,7 @@ export default function AdvancedEventLabDialog({
void fetchTranslateLanguages() void fetchTranslateLanguages()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
const filtered = filterTranslateLanguagesWithLanguageToolPairing(list) const filtered = filterTranslateLanguagesWithGrammarCatalog(list)
if (!filtered.length) { if (!filtered.length) {
setTranslateLangs([]) setTranslateLangs([])
setTranslateLoad('empty') setTranslateLoad('empty')
@ -795,12 +828,31 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="lt-lang" className="min-w-[220px] max-w-md w-auto"> <SelectTrigger id="lt-lang" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]"> <SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0">
{ltList.map((code) => ( <div
<SelectItem key={code} value={code}> className="sticky top-0 z-10 border-b border-border bg-popover p-2"
<LanguageSelectOptionLines tag={code} /> 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> </SelectItem>
))} ))}
</div>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -831,13 +883,34 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="tr-src" className="min-w-[220px] max-w-md w-auto"> <SelectTrigger id="tr-src" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]"> <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> <SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
{translateLangs.map((l) => ( ) : null}
<SelectItem key={l.code} value={l.code}> {translateLangsFilteredSrc.map((l) => (
<LanguageSelectOptionLines tag={l.code} /> <SelectItem
key={l.code}
value={l.code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={l.code} className="w-full" />
</SelectItem> </SelectItem>
))} ))}
</div>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -859,12 +932,31 @@ export default function AdvancedEventLabDialog({
<SelectTrigger id="tr-tgt" className="min-w-[220px] max-w-md w-auto"> <SelectTrigger id="tr-tgt" className="min-w-[220px] max-w-md w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)]"> <SelectContent className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0">
{translateLangs.map((l) => ( <div
<SelectItem key={l.code} value={l.code}> className="sticky top-0 z-10 border-b border-border bg-popover p-2"
<LanguageSelectOptionLines tag={l.code} /> 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> </SelectItem>
))} ))}
</div>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

114
src/components/NoteOptions/DesktopMenu.tsx

@ -8,46 +8,122 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { MenuAction } from './useMenuActions' import { MenuAction, SubMenuAction } from './useMenuActions'
import { memo } from 'react' import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface DesktopMenuProps { interface DesktopMenuProps {
menuActions: MenuAction[] menuActions: MenuAction[]
trigger: React.ReactNode trigger: React.ReactNode
} }
const MenuContent = memo(({ menuActions }: { menuActions: MenuAction[] }) => { function filterSubMenuRows(
return ( items: SubMenuAction[],
<> searchable: boolean | undefined,
{menuActions.map((action, index) => { 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 Icon = action.icon
const sub = action.subMenu ?? []
const filtered = useMemo(
() => filterSubMenuRows(sub, action.subMenuSearchable, subMenuFilter),
[sub, action.subMenuSearchable, subMenuFilter]
)
return ( return (
<div key={index}>
{action.separator && index > 0 && <DropdownMenuSeparator />}
{action.subMenu ? (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger className={action.className}> <DropdownMenuSubTrigger className={action.className}>
<Icon /> <Icon />
{action.label} {action.label}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent <DropdownMenuSubContent
className="max-h-[50vh] overflow-y-auto" className="w-[min(28rem,calc(100vw-2rem))] max-w-[28rem] min-w-[18rem] p-0"
showScrollButtons showScrollButtons
> >
{action.subMenu.map((subAction, subIndex) => ( {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>
) : (
filtered.map((subAction, subIndex) => (
<div key={subIndex}> <div key={subIndex}>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />} {subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem <DropdownMenuItem
onClick={subAction.onClick} onClick={subAction.onClick}
className={cn('w-64', subAction.className)} className={cn(
'min-w-0 max-w-none whitespace-normal',
subAction.className
)}
> >
{subAction.label} {subAction.label}
</DropdownMenuItem> </DropdownMenuItem>
</div> </div>
))} ))
)}
</div>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </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}> <DropdownMenuItem onClick={action.onClick} className={action.className}>
<Icon /> <Icon />
@ -59,15 +135,21 @@ const MenuContent = memo(({ menuActions }: { menuActions: MenuAction[] }) => {
})} })}
</> </>
) )
}) }
)
MenuContent.displayName = 'MenuContent' MenuContent.displayName = 'MenuContent'
export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) { export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) {
const [subMenuFilter, setSubMenuFilter] = useState('')
return ( return (
<DropdownMenu> <DropdownMenu onOpenChange={(open) => !open && setSubMenuFilter('')}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto"> <DropdownMenuContent className="max-h-[50vh] overflow-y-auto">
<MenuContent menuActions={menuActions} /> <MenuContent
menuActions={menuActions}
subMenuFilter={subMenuFilter}
setSubMenuFilter={setSubMenuFilter}
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )

53
src/components/NoteOptions/MobileMenu.tsx

@ -1,7 +1,11 @@
import { Button } from '@/components/ui/button' 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 { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { MenuAction, SubMenuAction } from './useMenuActions' import { MenuAction, SubMenuAction } from './useMenuActions'
import { useMemo, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface MobileMenuProps { interface MobileMenuProps {
menuActions: MenuAction[] menuActions: MenuAction[]
@ -11,10 +15,21 @@ interface MobileMenuProps {
showSubMenu: boolean showSubMenu: boolean
activeSubMenu: SubMenuAction[] activeSubMenu: SubMenuAction[]
subMenuTitle: string subMenuTitle: string
subMenuSearchable: boolean
closeDrawer: () => void closeDrawer: () => void
goBackToMainMenu: () => 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({ export function MobileMenu({
menuActions, menuActions,
trigger, trigger,
@ -23,9 +38,20 @@ export function MobileMenu({
showSubMenu, showSubMenu,
activeSubMenu, activeSubMenu,
subMenuTitle, subMenuTitle,
subMenuSearchable,
closeDrawer, closeDrawer,
goBackToMainMenu goBackToMainMenu
}: MobileMenuProps) { }: 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 ( return (
<> <>
{trigger} {trigger}
@ -62,16 +88,37 @@ export function MobileMenu({
{subMenuTitle} {subMenuTitle}
</Button> </Button>
<div className="border-t border-border mb-2" /> <div className="border-t border-border mb-2" />
{activeSubMenu.map((subAction, index) => ( {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 <Button
key={index} key={index}
onClick={subAction.onClick} onClick={subAction.onClick}
className={`w-full p-6 justify-start text-lg gap-4 ${subAction.className || ''}`} 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" variant="ghost"
> >
{subAction.label} {subAction.label}
</Button> </Button>
))} ))
)}
</> </>
)} )}
</div> </div>

13
src/components/NoteOptions/index.tsx

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

37
src/components/NoteOptions/useMenuActions.tsx

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

17
src/components/ReadAloudPlayerModal.tsx

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

4
src/i18n/locales/en.ts

@ -865,6 +865,8 @@ export default {
"Advanced lab translation languages empty": "Translate service returned no languages (check Docker / LibreTranslate).", "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 languages error": "Could not load languages from the translate service.",
"Advanced lab translation same source target": "Source and target language must differ.", "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", "Show original text": "Show original text",
"Showing original note text": "Showing original text for this note.", "Showing original note text": "Showing original text for this note.",
"Translate note": "Translate note", "Translate note": "Translate note",
@ -873,6 +875,8 @@ export default {
"Note translation failed": "Translation failed: {{message}}", "Note translation failed": "Translation failed: {{message}}",
"Read-aloud Piper English voice fallback title": "English voice in use", "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 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 not configured": "Translation URL is not set (VITE_TRANSLATE_URL).",
"Advanced lab translate done": "Translation inserted into the editor.", "Advanced lab translate done": "Translation inserted into the editor.",
"Advanced lab use translation read aloud": "Use body for read-aloud (this note)", "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 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('getLanguageDisplayParts', () => {
it('uses the static map for German', () => { it('uses the static map for German', () => {
@ -20,3 +26,29 @@ describe('getLanguageDisplayParts', () => {
expect(languageSelectSingleLine('tr')).toContain('Türkçe') 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 @@
* Canonical ISO-639-style language labels (English + endonym) for selection UI. * 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 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` * JSX: {@link LanguageSelectOptionLines} in `language-select-option-lines.tsx` (this file stays `.ts`
* so extensionless imports resolve cleanly under Vite). * so extensionless imports resolve cleanly under Vite).
*/ */
import type { TranslateLanguageOption } from '@/lib/translate-client'
import { normalizeTranslateLangCode } 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. */ /** 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 }> = export const LANGUAGE_TRIPLE_BY_LOWER_KEY: Record<string, { english: string; native: string }> =
@ -283,3 +292,93 @@ export function languageSelectSingleLine(tag: string): string {
const p = getLanguageDisplayParts(tag) const p = getLanguageDisplayParts(tag)
return `${p.codeLabel}${p.englishName}${p.nativeName}` 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 = {
className?: string 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 { export function LanguageSelectOptionLines({ tag, compact, className }: LinesProps): ReactElement {
const p = getLanguageDisplayParts(tag) const p = getLanguageDisplayParts(tag)
return ( 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 <span
className={cn( className={cn(
'font-mono text-muted-foreground tabular-nums', 'shrink-0 font-mono tabular-nums text-muted-foreground',
compact ? 'text-[10px]' : 'text-xs' compact ? 'text-[10px]' : 'text-xs'
)} )}
> >
{p.codeLabel} {p.codeLabel}
</span> </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} {p.englishName}
</span> </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} {p.nativeName}
</span> </span>
</div> </div>

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

@ -35,16 +35,22 @@ describe('pickLanguageToolCodeForTranslateTarget', () => {
}) })
describe('buildLabLanguageToolPreferenceList', () => { 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', [ const list = buildLabLanguageToolPreferenceList('de', [
{ code: 'fr', name: 'French' }, { code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' } { 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[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).toEqual(['de-DE', 'en-US', 'fr-FR'])
expect(list.indexOf('en-US')).toBe(1)
expect(list.includes('fr-FR')).toBe(true)
expect(list.includes('es')).toBe(true)
}) })
}) })

43
src/lib/read-aloud.ts

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

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

@ -1,21 +1,49 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages' import { getPiperVoiceForChosenLanguage, TRINITY_PIPER_VOICE } from '@/lib/trinity-languages'
describe('filterTranslateLanguagesWithLanguageToolPairing', () => { describe('getPiperVoiceForChosenLanguage', () => {
it('dedupes by LanguageTool grammar code and prefers shorter API codes', () => { it('uses native Piper for trinity codes', () => {
const list = filterTranslateLanguagesWithLanguageToolPairing([ const r = getPiperVoiceForChosenLanguage('de')
{ code: 'zh-CN', name: 'Chinese (Simplified)' }, expect(r.voice).toBe(TRINITY_PIPER_VOICE.de)
{ code: 'zh', name: 'Chinese' }, expect(r.usedEnglishVoiceFallback).toBe(false)
{ code: 'de', name: 'German' } expect(r.usedRelatedVoiceFallback).toBe(false)
]) expect(r.piperProfileCode).toBe('de')
expect(list.map((l) => l.code).sort()).toEqual(['de', 'zh'])
}) })
it('drops codes with no LT pairing', () => { it('routes Japanese and Korean to Chinese Piper', () => {
const list = filterTranslateLanguagesWithLanguageToolPairing([ const ja = getPiperVoiceForChosenLanguage('ja')
{ code: 'en', name: 'English' }, expect(ja.voice).toBe(TRINITY_PIPER_VOICE.zh)
{ code: 'zz-fake', name: 'Fake' } expect(ja.usedRelatedVoiceFallback).toBe(true)
]) expect(ja.piperProfileCode).toBe('zh')
expect(list.map((l) => l.code)).toEqual(['en'])
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 @@
/** /**
* Piper native voices: codes with an entry in `TRINITY_PIPER_VOICE` match `getVoiceForLanguage` in * Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`.
* `services/piper-tts-proxy/server.ts`. Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native * Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then
* Piper for those codes, **English Piper** for every other translate target. * **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 * **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`:
* have an explicit LanguageTool mapping (`translateCodeHasLanguageToolPairing` in * Libre `/languages` LanguageTool pairing (installed translate targets only).
* `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 { import {
translateCodeHasLanguageToolPairing, normalizeTranslateLangCode,
translateTargetToLanguageToolCode type TranslateLanguageOption
} from '@/lib/languagetool-language-order' } from '@/lib/translate-client'
import { translateTargetToLanguageToolCode } from '@/lib/languagetool-language-order'
export const TRINITY_LANGUAGE_CODES = [ export const TRINITY_LANGUAGE_CODES = [
'en', 'en',
@ -85,70 +83,132 @@ export function trinityLanguageToolCode(code: string): string {
return translateTargetToLanguageToolCode(code) 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 * ISO 639-1 (or base BCP47 segment) trinity Piper voice to approximate when we have no native model.
* (one row per LanguageTool language; prefers shorter API codes like `zh` over `zh-CN`). * Keep conservative: same-script / regional neighbors only where it helps more than English.
*/ */
export function filterTranslateLanguagesWithLanguageToolPairing( const RELATED_PIPER_FOR_BASE: Record<string, TrinityLanguageCode> = {
list: TranslateLanguageOption[] ja: 'zh',
): TranslateLanguageOption[] { ko: 'zh',
const withPairing = list.filter((l) => translateCodeHasLanguageToolPairing(l.code)) uk: 'ru',
const byLt = new Map<string, TranslateLanguageOption>() be: 'ru',
for (const l of withPairing) { bg: 'ru',
const lt = translateTargetToLanguageToolCode(l.code) mk: 'ru',
const prev = byLt.get(lt) sr: 'ru',
if (!prev || l.code.trim().length < prev.code.trim().length) { bs: 'ru',
byLt.set(lt, l) kk: 'ru',
} ky: 'ru',
} mn: 'ru',
return Array.from(byLt.values()).sort((a, b) => tg: 'ru',
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) 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): { export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): PiperVoiceResolution {
voice: string
usedEnglishVoiceFallback: boolean
} {
return { return {
voice: TRINITY_PIPER_VOICE[lang], 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). */ function baseLangTag(raw: string): string {
export function getPiperVoiceForChosenLanguage(lang: string): { const n = normalizeTranslateLangCode(raw).toLowerCase().replace(/_/gu, '-')
voice: string return n.split(/-/u)[0] ?? n
usedEnglishVoiceFallback: boolean }
} {
if (isTrinityLanguageCode(lang)) { /** Piper voice for read-aloud: native trinity → related trinity → English. */
return getPiperVoiceForTrinityLanguage(lang) 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 { return {
voice: TRINITY_FALLBACK_ENGLISH_VOICE, 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 * LanguageTool `language` dropdown for the lab: same logical set as the translate targets (Libre
* translate target (from the filtered LibreTranslate list). * `/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( export function buildLabLanguageToolPreferenceList(
i18nLanguage: string | undefined, i18nLanguage: string | undefined,
translateLangs: readonly TranslateLanguageOption[] translateLangs: readonly TranslateLanguageOption[]
): string[] { ): string[] {
const allowedLt = new Set(
translateLangs.map((l) => translateTargetToLanguageToolCode(l.code))
)
const ordered: string[] = [] const ordered: string[] = []
const push = (c: 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' const raw = (i18nLanguage ?? 'en').trim() || 'en'
push(translateTargetToLanguageToolCode(raw)) push(translateTargetToLanguageToolCode(raw))
push('en-US') push('en-US')
const extras = translateLangs.map((l) => translateTargetToLanguageToolCode(l.code)) for (const l of translateLangs) {
extras.sort((a, b) => a.localeCompare(b)) push(translateTargetToLanguageToolCode(l.code))
for (const c of extras) {
push(c)
} }
return ordered return ordered
} }

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

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

Loading…
Cancel
Save