diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index a15005c2..9453cee6 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -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' 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({ 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({ 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({ - - {ltList.map((code) => ( - - - - ))} + +
e.stopPropagation()} + > + setLtLangFilter(e.target.value)} + placeholder={t('Language list filter placeholder')} + className="h-8" + aria-label={t('Language list filter placeholder')} + /> +
+
+ {ltListFiltered.map((code) => ( + + + + ))} +
@@ -831,13 +883,34 @@ export default function AdvancedEventLabDialog({ - - {t('Advanced lab translation source auto')} - {translateLangs.map((l) => ( - - - - ))} + +
e.stopPropagation()} + > + setTranslateSrcFilter(e.target.value)} + placeholder={t('Language list filter placeholder')} + className="h-8" + aria-label={t('Language list filter placeholder')} + /> +
+
+ {showTranslateSourceAuto ? ( + {t('Advanced lab translation source auto')} + ) : null} + {translateLangsFilteredSrc.map((l) => ( + + + + ))} +
@@ -859,12 +932,31 @@ export default function AdvancedEventLabDialog({ - - {translateLangs.map((l) => ( - - - - ))} + +
e.stopPropagation()} + > + setTranslateTgtFilter(e.target.value)} + placeholder={t('Language list filter placeholder')} + className="h-8" + aria-label={t('Language list filter placeholder')} + /> +
+
+ {translateLangsFilteredTgt.map((l) => ( + + + + ))} +
diff --git a/src/components/NoteOptions/DesktopMenu.tsx b/src/components/NoteOptions/DesktopMenu.tsx index 7f66823f..b6df137b 100644 --- a/src/components/NoteOptions/DesktopMenu.tsx +++ b/src/components/NoteOptions/DesktopMenu.tsx @@ -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 ( -
- {action.separator && index > 0 && } - {action.subMenu ? ( - - - - {action.label} - - - {action.subMenu.map((subAction, subIndex) => ( -
- {subAction.separator && subIndex > 0 && } - - {subAction.label} - -
- ))} -
-
+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 ( + + + + {action.label} + + + {action.subMenuSearchable ? ( +
e.stopPropagation()} + > + setSubMenuFilter(e.target.value)} + placeholder={t('Language list filter placeholder')} + className="h-8" + aria-label={t('Language list filter placeholder')} + /> +
+ ) : null} +
+ {filtered.length === 0 ? ( +
+ {t('Language list filter empty')} +
) : ( - - - {action.label} - + filtered.map((subAction, subIndex) => ( +
+ {subAction.separator && subIndex > 0 && } + + {subAction.label} + +
+ )) )}
- ) - })} - - ) -}) +
+
+ ) + } +) +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 ( +
+ {action.separator && index > 0 && } + {action.subMenu ? ( + + ) : ( + + + {action.label} + + )} +
+ ) + })} + + ) + } +) MenuContent.displayName = 'MenuContent' export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) { + const [subMenuFilter, setSubMenuFilter] = useState('') return ( - + !open && setSubMenuFilter('')}> {trigger} - + ) diff --git a/src/components/NoteOptions/MobileMenu.tsx b/src/components/NoteOptions/MobileMenu.tsx index e507385a..ddb348b5 100644 --- a/src/components/NoteOptions/MobileMenu.tsx +++ b/src/components/NoteOptions/MobileMenu.tsx @@ -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 { 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({ 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({ {subMenuTitle}
- {activeSubMenu.map((subAction, index) => ( - - ))} + {subMenuSearchable ? ( +
+ setSubMenuFilter(e.target.value)} + placeholder={t('Language list filter placeholder')} + className="h-10" + aria-label={t('Language list filter placeholder')} + /> +
+ ) : null} + {filteredSubMenu.length === 0 ? ( +

+ {t('Language list filter empty')} +

+ ) : ( + filteredSubMenu.map((subAction, index) => ( + + )) + )} )}
diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index 558d0606..11c673df 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -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({ const [showSubMenu, setShowSubMenu] = useState(false) const [activeSubMenu, setActiveSubMenu] = useState([]) 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({ showSubMenu={showSubMenu} activeSubMenu={activeSubMenu} subMenuTitle={subMenuTitle} + subMenuSearchable={subMenuSearchable} closeDrawer={closeDrawer} goBackToMainMenu={goBackToMainMenu} /> diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 60715a2c..769ddcbc 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -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 { 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 { 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({ () => getNoteTranslation(event.id) ) - const [translateMenuOptions, setTranslateMenuOptions] = useState([]) + const [translateMenuOptions, setTranslateMenuOptions] = + useState(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({ ? [ { 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({ }, ...translateMenuOptions.map( (opt, i): SubMenuAction => ({ - label: , + label: , + 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({ 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 ] : []), diff --git a/src/components/ReadAloudPlayerModal.tsx b/src/components/ReadAloudPlayerModal.tsx index 5034cb4d..8496fe55 100644 --- a/src/components/ReadAloudPlayerModal.tsx +++ b/src/components/ReadAloudPlayerModal.tsx @@ -115,17 +115,32 @@ export default function ReadAloudPlayerModal(): JSX.Element {

{snap.title}

) : null}

{phaseLabel(snap, t)}

- {snap.piperUsedEnglishVoiceFallback && snap.piperVoiceRequestedLanguageName ? ( + {(snap.piperUsedEnglishVoiceFallback || snap.piperUsedRelatedVoiceFallback) && + snap.piperVoiceRequestedLanguageName ? (
-

{t('Read-aloud Piper English voice fallback title')}

-

- {t('Read-aloud Piper English voice fallback detail', { - language: snap.piperVoiceRequestedLanguageName - })} -

+ {snap.piperUsedEnglishVoiceFallback ? ( + <> +

{t('Read-aloud Piper English voice fallback title')}

+

+ {t('Read-aloud Piper English voice fallback detail', { + language: snap.piperVoiceRequestedLanguageName + })} +

+ + ) : ( + <> +

{t('Read-aloud Piper related voice fallback title')}

+

+ {t('Read-aloud Piper related voice fallback detail', { + language: snap.piperVoiceRequestedLanguageName, + profile: snap.piperVoiceProfileName + })} +

+ + )}
) : null} {snap.engine === 'piper' ? ( diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 56f14c1e..6e3a2a14 100644 --- a/src/i18n/locales/en.ts +++ b/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 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 { "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)", diff --git a/src/lib/language-display-meta.test.ts b/src/lib/language-display-meta.test.ts index 30bb0e5c..f0bdc0cc 100644 --- a/src/lib/language-display-meta.test.ts +++ b/src/lib/language-display-meta.test.ts @@ -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', () => { 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) + }) +}) diff --git a/src/lib/language-display-meta.ts b/src/lib/language-display-meta.ts index 619f142b..0f31c51e 100644 --- a/src/lib/language-display-meta.ts +++ b/src/lib/language-display-meta.ts @@ -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 = @@ -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() + 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() + 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' diff --git a/src/lib/language-select-option-lines.tsx b/src/lib/language-select-option-lines.tsx index 1a040fb9..67ae31c2 100644 --- a/src/lib/language-select-option-lines.tsx +++ b/src/lib/language-select-option-lines.tsx @@ -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 ( -
+
{p.codeLabel} - + {p.englishName} - + {p.nativeName}
diff --git a/src/lib/languagetool-language-order.test.ts b/src/lib/languagetool-language-order.test.ts index a4fb9389..49749f60 100644 --- a/src/lib/languagetool-language-order.test.ts +++ b/src/lib/languagetool-language-order.test.ts @@ -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']) }) }) diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index 63f41d64..f940c7de 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -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 = { 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 = { volume: 1, backend: '', piperUsedEnglishVoiceFallback: false, - piperVoiceRequestedLanguageName: '' + piperUsedRelatedVoiceFallback: false, + piperVoiceRequestedLanguageName: '', + piperVoiceProfileName: '' } let snapshot: ReadAloudSnapshot = { ...initialSnapshot } @@ -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 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 readAloudPiperTryStartedAt: Date.now(), backend: readAloudEndpointForLog(), piperUsedEnglishVoiceFallback: usedEnglishVoiceFallback, - piperVoiceRequestedLanguageName + piperUsedRelatedVoiceFallback: usedRelatedVoiceFallback, + piperVoiceRequestedLanguageName, + piperVoiceProfileName }) await yieldForReadAloudUi() diff --git a/src/lib/trinity-languages.test.ts b/src/lib/trinity-languages.test.ts index abdf4ac9..2a02fa04 100644 --- a/src/lib/trinity-languages.test.ts +++ b/src/lib/trinity-languages.test.ts @@ -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') }) }) diff --git a/src/lib/trinity-languages.ts b/src/lib/trinity-languages.ts index 8d227535..65326faa 100644 --- a/src/lib/trinity-languages.ts +++ b/src/lib/trinity-languages.ts @@ -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 { 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() - 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 = { + 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 language’s 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 language’s 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 } diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index 65d73457..402512c9 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -84,8 +84,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index {SUPPORTED_APP_LANGUAGE_CODES.map((key) => ( - - + + ))}