29 changed files with 1452 additions and 19 deletions
@ -0,0 +1,392 @@
@@ -0,0 +1,392 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { |
||||
Select, |
||||
SelectContent, |
||||
SelectItem, |
||||
SelectTrigger, |
||||
SelectValue |
||||
} from '@/components/ui/select' |
||||
import { isLanguageToolConfigured } from '@/lib/languagetool-client' |
||||
import { languageToolLintExtension } from '@/lib/languagetool-cm-linter' |
||||
import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' |
||||
import { |
||||
parseLabSlice, |
||||
serializeLabSlice, |
||||
type AdvancedEventLabSlice |
||||
} from '@/lib/advanced-event-lab-slice' |
||||
import { isTranslateConfigured, translatePlainText } from '@/lib/translate-client' |
||||
import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' |
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' |
||||
import { markdown } from '@codemirror/lang-markdown' |
||||
import { json } from '@codemirror/lang-json' |
||||
import { StreamLanguage } from '@codemirror/language' |
||||
import { asciidoc } from 'codemirror-asciidoc' |
||||
import { EditorState, type Extension } from '@codemirror/state' |
||||
import { oneDark } from '@codemirror/theme-one-dark' |
||||
import { |
||||
EditorView, |
||||
keymap, |
||||
lineNumbers, |
||||
placeholder as cmPlaceholder |
||||
} from '@codemirror/view' |
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
|
||||
export type AdvancedEventLabDialogProps = { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
/** Snapshot when opening; parent should memoize. */ |
||||
initial: AdvancedEventLabSlice | null |
||||
/** When false, `kind` in JSON is shown but Apply forces `initial.kind`. */ |
||||
kindEditable?: boolean |
||||
markupMode: 'markdown' | 'asciidoc' |
||||
/** `i18n.language` for LanguageTool default ordering. */ |
||||
i18nLanguage?: string |
||||
/** When set, user can store translation for read-aloud for this event id. */ |
||||
contextEventId?: string | null |
||||
onApply: (payload: AdvancedEventLabSlice) => void |
||||
} |
||||
|
||||
function useDarkModeFlag(): boolean { |
||||
const [dark, setDark] = useState(() => |
||||
typeof document !== 'undefined' |
||||
? document.documentElement.classList.contains('dark') |
||||
: false |
||||
) |
||||
useEffect(() => { |
||||
const el = document.documentElement |
||||
const obs = new MutationObserver(() => { |
||||
setDark(el.classList.contains('dark')) |
||||
}) |
||||
obs.observe(el, { attributes: true, attributeFilter: ['class'] }) |
||||
return () => obs.disconnect() |
||||
}, []) |
||||
return dark |
||||
} |
||||
|
||||
export default function AdvancedEventLabDialog({ |
||||
open, |
||||
onOpenChange, |
||||
initial, |
||||
kindEditable = true, |
||||
markupMode, |
||||
i18nLanguage, |
||||
contextEventId, |
||||
onApply |
||||
}: AdvancedEventLabDialogProps) { |
||||
const { t, i18n } = useTranslation() |
||||
const dark = useDarkModeFlag() |
||||
const markupHost = useRef<HTMLDivElement>(null) |
||||
const jsonHost = useRef<HTMLDivElement>(null) |
||||
const markupView = useRef<EditorView | null>(null) |
||||
const jsonView = useRef<EditorView | null>(null) |
||||
const sliceRef = useRef<AdvancedEventLabSlice | null>(null) |
||||
const syncing = useRef(false) |
||||
|
||||
const ltList = useMemo( |
||||
() => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language), |
||||
[i18nLanguage, i18n.language] |
||||
) |
||||
const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') |
||||
const [jsonError, setJsonError] = useState<string | null>(null) |
||||
const [translateTarget, setTranslateTarget] = useState('en') |
||||
|
||||
useEffect(() => { |
||||
if (open) { |
||||
setLtLang(ltList[0] ?? 'en-US') |
||||
} |
||||
}, [open, ltList]) |
||||
|
||||
const destroyEditors = useCallback(() => { |
||||
markupView.current?.destroy() |
||||
jsonView.current?.destroy() |
||||
markupView.current = null |
||||
jsonView.current = null |
||||
}, []) |
||||
|
||||
useEffect(() => { |
||||
if (!open || !initial) { |
||||
destroyEditors() |
||||
return |
||||
} |
||||
const mkEl = markupHost.current |
||||
const jsEl = jsonHost.current |
||||
if (!mkEl || !jsEl) return |
||||
|
||||
destroyEditors() |
||||
|
||||
const baseSlice: AdvancedEventLabSlice = { |
||||
kind: initial.kind, |
||||
content: initial.content, |
||||
tags: initial.tags.map((row) => [...row]) |
||||
} |
||||
sliceRef.current = baseSlice |
||||
|
||||
const markupLang: Extension = |
||||
markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown() |
||||
|
||||
const mkExtensions: Extension[] = [ |
||||
history(), |
||||
keymap.of([...defaultKeymap, ...historyKeymap]), |
||||
lineNumbers(), |
||||
cmPlaceholder(t('Advanced lab markup placeholder')), |
||||
markupLang, |
||||
EditorView.theme({ |
||||
'&': { maxHeight: '100%' }, |
||||
'.cm-scroller': { overflow: 'auto' }, |
||||
'.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } |
||||
}), |
||||
EditorView.updateListener.of((update) => { |
||||
if (!update.docChanged || syncing.current) return |
||||
const content = update.state.doc.toString() |
||||
const s = sliceRef.current |
||||
if (!s) return |
||||
s.content = content |
||||
const jv = jsonView.current |
||||
if (!jv) return |
||||
const nextJson = serializeLabSlice({ |
||||
kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), |
||||
content: s.content, |
||||
tags: s.tags |
||||
}) |
||||
if (jv.state.doc.toString() === nextJson) return |
||||
syncing.current = true |
||||
jv.dispatch({ |
||||
changes: { from: 0, to: jv.state.doc.length, insert: nextJson }, |
||||
selection: { anchor: 0 } |
||||
}) |
||||
syncing.current = false |
||||
}) |
||||
] |
||||
if (isLanguageToolConfigured()) { |
||||
mkExtensions.push(languageToolLintExtension(ltLang, 450)) |
||||
} |
||||
if (dark) mkExtensions.push(oneDark) |
||||
|
||||
const jsonExtensions: Extension[] = [ |
||||
history(), |
||||
keymap.of([...defaultKeymap, ...historyKeymap]), |
||||
lineNumbers(), |
||||
json(), |
||||
cmPlaceholder(t('Advanced lab json placeholder')), |
||||
EditorView.theme({ |
||||
'&': { maxHeight: '100%' }, |
||||
'.cm-scroller': { overflow: 'auto' }, |
||||
'.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } |
||||
}), |
||||
EditorView.updateListener.of((update) => { |
||||
if (!update.docChanged || syncing.current) return |
||||
const parsed = parseLabSlice(update.state.doc.toString()) |
||||
if (!parsed.ok) { |
||||
setJsonError(parsed.error) |
||||
return |
||||
} |
||||
setJsonError(null) |
||||
const fixedKind = kindEditable ? parsed.value.kind : (initial.kind ?? parsed.value.kind) |
||||
const next: AdvancedEventLabSlice = { |
||||
kind: fixedKind, |
||||
content: parsed.value.content, |
||||
tags: parsed.value.tags |
||||
} |
||||
sliceRef.current = next |
||||
const mv = markupView.current |
||||
if (mv && mv.state.doc.toString() !== next.content) { |
||||
syncing.current = true |
||||
mv.dispatch({ |
||||
changes: { from: 0, to: mv.state.doc.length, insert: next.content }, |
||||
selection: { anchor: Math.min(mv.state.selection.main.anchor, next.content.length) } |
||||
}) |
||||
syncing.current = false |
||||
} |
||||
}) |
||||
] |
||||
if (dark) jsonExtensions.push(oneDark) |
||||
|
||||
const mkState = EditorState.create({ |
||||
doc: baseSlice.content, |
||||
extensions: mkExtensions |
||||
}) |
||||
const jsState = EditorState.create({ |
||||
doc: serializeLabSlice({ |
||||
kind: baseSlice.kind, |
||||
content: baseSlice.content, |
||||
tags: baseSlice.tags |
||||
}), |
||||
extensions: jsonExtensions |
||||
}) |
||||
|
||||
markupView.current = new EditorView({ state: mkState, parent: mkEl }) |
||||
jsonView.current = new EditorView({ state: jsState, parent: jsEl }) |
||||
|
||||
return destroyEditors |
||||
}, [ |
||||
open, |
||||
initial, |
||||
markupMode, |
||||
ltLang, |
||||
kindEditable, |
||||
dark, |
||||
destroyEditors, |
||||
t |
||||
]) |
||||
|
||||
const handleApply = () => { |
||||
const raw = jsonView.current?.state.doc.toString() ?? '' |
||||
const parsed = parseLabSlice(raw) |
||||
if (!parsed.ok) { |
||||
toast.error(parsed.error) |
||||
return |
||||
} |
||||
const kind = kindEditable ? parsed.value.kind : (initial?.kind ?? parsed.value.kind) |
||||
const payload: AdvancedEventLabSlice = { |
||||
kind, |
||||
content: parsed.value.content, |
||||
tags: parsed.value.tags |
||||
} |
||||
onApply(payload) |
||||
onOpenChange(false) |
||||
} |
||||
|
||||
const handleTranslate = async () => { |
||||
if (!isTranslateConfigured()) { |
||||
toast.message(t('Advanced lab translate not configured')) |
||||
return |
||||
} |
||||
const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? '' |
||||
if (!text.trim()) return |
||||
try { |
||||
const out = await translatePlainText(text, translateTarget.trim() || 'en') |
||||
if (!markupView.current) return |
||||
syncing.current = true |
||||
markupView.current.dispatch({ |
||||
changes: { from: 0, to: markupView.current.state.doc.length, insert: out } |
||||
}) |
||||
syncing.current = false |
||||
const s = sliceRef.current |
||||
if (s) { |
||||
s.content = out |
||||
const jv = jsonView.current |
||||
if (jv) { |
||||
const nextJson = serializeLabSlice({ |
||||
kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), |
||||
content: out, |
||||
tags: s.tags |
||||
}) |
||||
syncing.current = true |
||||
jv.dispatch({ changes: { from: 0, to: jv.state.doc.length, insert: nextJson } }) |
||||
syncing.current = false |
||||
} |
||||
} |
||||
toast.success(t('Advanced lab translate done')) |
||||
} catch (e) { |
||||
toast.error(e instanceof Error ? e.message : String(e)) |
||||
} |
||||
} |
||||
|
||||
const handleReadAloudBuffer = () => { |
||||
if (!contextEventId) return |
||||
const text = markupView.current?.state.doc.toString().trim() ?? '' |
||||
if (!text) return |
||||
setReadAloudTranslationForEvent(contextEventId, text) |
||||
toast.success(t('Advanced lab read aloud buffer set')) |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="z-[250] max-h-[92vh] w-[min(96vw,72rem)] flex flex-col gap-0 p-0 overflow-hidden"> |
||||
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b"> |
||||
<DialogTitle>{t('Advanced event lab')}</DialogTitle> |
||||
<DialogDescription className="text-left"> |
||||
{t('Advanced lab hint')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
|
||||
<div className="flex flex-col gap-2 px-4 py-2 border-b shrink-0 flex-wrap"> |
||||
<div className="flex flex-wrap items-end gap-3"> |
||||
{isLanguageToolConfigured() ? ( |
||||
<div className="space-y-1 min-w-[10rem]"> |
||||
<Label htmlFor="lt-lang">{t('Advanced lab grammar language')}</Label> |
||||
<Select value={ltLang} onValueChange={setLtLang}> |
||||
<SelectTrigger id="lt-lang" className="w-[220px]"> |
||||
<SelectValue /> |
||||
</SelectTrigger> |
||||
<SelectContent className="max-h-64"> |
||||
{ltList.map((code) => ( |
||||
<SelectItem key={code} value={code}> |
||||
{code} |
||||
</SelectItem> |
||||
))} |
||||
</SelectContent> |
||||
</Select> |
||||
</div> |
||||
) : null} |
||||
{isTranslateConfigured() ? ( |
||||
<div className="flex flex-wrap items-end gap-2"> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label> |
||||
<Input |
||||
id="tr-tgt" |
||||
className="w-24 font-mono text-sm" |
||||
value={translateTarget} |
||||
onChange={(e) => setTranslateTarget(e.target.value)} |
||||
placeholder="en" |
||||
/> |
||||
</div> |
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}> |
||||
{t('Advanced lab translate')} |
||||
</Button> |
||||
</div> |
||||
) : null} |
||||
{contextEventId && isTranslateConfigured() ? ( |
||||
<Button type="button" variant="outline" size="sm" onClick={handleReadAloudBuffer}> |
||||
{t('Advanced lab use translation read aloud')} |
||||
</Button> |
||||
) : null} |
||||
</div> |
||||
{jsonError ? ( |
||||
<p className="text-sm text-destructive" role="alert"> |
||||
{jsonError} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-2 px-4 py-2 overflow-hidden"> |
||||
<div className="flex flex-col min-h-0 gap-1"> |
||||
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab markup')}</span> |
||||
<div |
||||
ref={markupHost} |
||||
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20" |
||||
/> |
||||
</div> |
||||
<div className="flex flex-col min-h-0 gap-1"> |
||||
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab tags JSON')}</span> |
||||
<div |
||||
ref={jsonHost} |
||||
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<DialogFooter className="shrink-0 px-4 py-3 border-t gap-2"> |
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="button" onClick={handleApply} disabled={Boolean(jsonError)}> |
||||
{t('Apply')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
|
||||
/** Kinds whose body is AsciiDoc in Imwald (wiki article, publication content). */ |
||||
export function isAsciidocMarkupKind(kind: number): boolean { |
||||
return kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.PUBLICATION_CONTENT |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { parseLabSlice, serializeLabSlice } from '@/lib/advanced-event-lab-slice' |
||||
|
||||
describe('parseLabSlice', () => { |
||||
it('round-trips', () => { |
||||
const v = { kind: 1, content: 'hello', tags: [['e', 'abc'], ['p', 'def']] } |
||||
const s = serializeLabSlice(v) |
||||
const p = parseLabSlice(s) |
||||
expect(p).toEqual({ ok: true, value: v }) |
||||
}) |
||||
|
||||
it('rejects bad kind', () => { |
||||
expect(parseLabSlice('{"kind":"x","content":"","tags":[]}').ok).toBe(false) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
export type AdvancedEventLabSlice = { |
||||
kind: number |
||||
content: string |
||||
tags: string[][] |
||||
} |
||||
|
||||
export function serializeLabSlice(slice: AdvancedEventLabSlice): string { |
||||
return JSON.stringify( |
||||
{ |
||||
kind: slice.kind, |
||||
content: slice.content, |
||||
tags: slice.tags |
||||
}, |
||||
null, |
||||
2 |
||||
) |
||||
} |
||||
|
||||
export function parseLabSlice( |
||||
raw: string |
||||
): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } { |
||||
let o: unknown |
||||
try { |
||||
o = JSON.parse(raw) |
||||
} catch { |
||||
return { ok: false, error: 'Invalid JSON' } |
||||
} |
||||
if (!o || typeof o !== 'object' || Array.isArray(o)) { |
||||
return { ok: false, error: 'Root must be an object' } |
||||
} |
||||
const rec = o as Record<string, unknown> |
||||
if (typeof rec.kind !== 'number' || !Number.isFinite(rec.kind) || !Number.isInteger(rec.kind)) { |
||||
return { ok: false, error: '`kind` must be an integer' } |
||||
} |
||||
if (typeof rec.content !== 'string') { |
||||
return { ok: false, error: '`content` must be a string' } |
||||
} |
||||
if (!Array.isArray(rec.tags)) { |
||||
return { ok: false, error: '`tags` must be an array' } |
||||
} |
||||
const tags: string[][] = [] |
||||
for (let i = 0; i < rec.tags.length; i++) { |
||||
const row = rec.tags[i] |
||||
if (!Array.isArray(row) || !row.every((c) => typeof c === 'string')) { |
||||
return { ok: false, error: `tags[${i}] must be an array of strings` } |
||||
} |
||||
tags.push([...row]) |
||||
} |
||||
return { ok: true, value: { kind: rec.kind, content: rec.content, tags } } |
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { LANGUAGE_TOOL_URL } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
|
||||
export type LanguageToolMatch = { |
||||
offset: number |
||||
length: number |
||||
message: string |
||||
replacements?: Array<{ value: string }> |
||||
rule?: { id?: string; description?: string } |
||||
} |
||||
|
||||
export type LanguageToolCheckResponse = { |
||||
matches?: LanguageToolMatch[] |
||||
software?: { name?: string; version?: string } |
||||
language?: { name?: string; code?: string } |
||||
} |
||||
|
||||
function checkUrl(): string | null { |
||||
const base = LANGUAGE_TOOL_URL.trim().replace(/\/$/u, '') |
||||
if (!base) return null |
||||
return `${base}/v2/check` |
||||
} |
||||
|
||||
export async function languageToolCheck( |
||||
text: string, |
||||
language: string, |
||||
signal?: AbortSignal |
||||
): Promise<LanguageToolCheckResponse> { |
||||
const url = checkUrl() |
||||
if (!url) { |
||||
return { matches: [] } |
||||
} |
||||
const body = new URLSearchParams() |
||||
body.set('text', text) |
||||
body.set('language', language) |
||||
body.set('enabledOnly', 'false') |
||||
|
||||
const res = await fetch(url, { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
||||
body: body.toString(), |
||||
signal |
||||
}) |
||||
if (!res.ok) { |
||||
const errText = await res.text().catch(() => '') |
||||
logger.warn('[LanguageTool] HTTP error', { status: res.status, errText: errText.slice(0, 200) }) |
||||
throw new Error(`LanguageTool: ${res.status}`) |
||||
} |
||||
return (await res.json()) as LanguageToolCheckResponse |
||||
} |
||||
|
||||
export function isLanguageToolConfigured(): boolean { |
||||
return Boolean(checkUrl()) |
||||
} |
||||
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
import { linter, type Diagnostic } from '@codemirror/lint' |
||||
import type { Extension } from '@codemirror/state' |
||||
import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' |
||||
|
||||
function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null { |
||||
const from = Math.max(0, Math.min(m.offset, docLen)) |
||||
const to = Math.max(from, Math.min(m.offset + m.length, docLen)) |
||||
if (to <= from) return null |
||||
const fix = m.replacements?.[0]?.value |
||||
return { |
||||
from, |
||||
to, |
||||
severity: 'info', |
||||
message: m.message + (m.rule?.id ? ` (${m.rule.id})` : ''), |
||||
actions: fix |
||||
? [ |
||||
{ |
||||
name: 'Apply', |
||||
apply(view) { |
||||
view.dispatch({ changes: { from, to, insert: fix } }) |
||||
} |
||||
} |
||||
] |
||||
: undefined |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`. |
||||
*/ |
||||
export function languageToolLintExtension( |
||||
language: string, |
||||
debounceMs: number |
||||
): Extension { |
||||
return linter((view) => { |
||||
return new Promise<Diagnostic[]>((resolve) => { |
||||
const text = view.state.doc.toString() |
||||
if (text.length < 3) { |
||||
resolve([]) |
||||
return |
||||
} |
||||
const seq = ++requestSeq |
||||
window.setTimeout(() => { |
||||
if (seq !== requestSeq) return |
||||
void languageToolCheck(text, language) |
||||
.then((res) => { |
||||
if (seq !== requestSeq) return |
||||
const docLen = view.state.doc.length |
||||
const out: Diagnostic[] = [] |
||||
for (const m of res.matches ?? []) { |
||||
const d = matchToDiagnostic(docLen, m) |
||||
if (d) out.push(d) |
||||
} |
||||
resolve(out) |
||||
}) |
||||
.catch(() => resolve([])) |
||||
}, debounceMs) |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
let requestSeq = 0 |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' |
||||
|
||||
describe('buildLanguageToolPreferenceList', () => { |
||||
it('puts client language first then en-US then de-DE', () => { |
||||
const list = buildLanguageToolPreferenceList('de') |
||||
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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
|
||||
/** |
||||
* Build LanguageTool `language` codes with UI language first, then English, then German, then others. |
||||
* @see https://api.languagetool.org/v2/languages
|
||||
*/ |
||||
const LT_ALIASES: Record<string, string> = { |
||||
en: 'en-US', |
||||
de: 'de-DE', |
||||
fr: 'fr-FR', |
||||
es: 'es', |
||||
it: 'it', |
||||
pt: 'pt-BR', |
||||
'pt-BR': 'pt-BR', |
||||
'pt-PT': 'pt-PT', |
||||
pl: 'pl-PL', |
||||
ru: 'ru-RU', |
||||
uk: 'uk-UA', |
||||
nl: 'nl', |
||||
sv: 'sv', |
||||
da: 'da-DK', |
||||
no: 'no', |
||||
nb: 'no', |
||||
nn: 'no', |
||||
fi: 'fi', |
||||
el: 'el-GR', |
||||
tr: 'tr', |
||||
ar: 'ar', |
||||
he: 'he', |
||||
hi: 'hi', |
||||
ja: 'ja-JP', |
||||
ko: 'ko', |
||||
zh: 'zh-CN', |
||||
fa: 'fa', |
||||
th: 'th-TH', |
||||
vi: 'vi-VN', |
||||
ro: 'ro-RO', |
||||
cs: 'cs-CZ', |
||||
sk: 'sk-SK', |
||||
hu: 'hu', |
||||
sl: 'sl-SI', |
||||
hr: 'hr-HR', |
||||
sr: 'sr', |
||||
bg: 'bg-BG', |
||||
lt: 'lt-LT', |
||||
lv: 'lv-LV', |
||||
et: 'et-EE', |
||||
ca: 'ca-ES', |
||||
gl: 'gl-ES', |
||||
tl: 'tl-PH', |
||||
id: 'id', |
||||
ms: 'ms-MY', |
||||
ta: 'ta-IN', |
||||
te: 'te-IN', |
||||
mr: 'mr-IN', |
||||
bn: 'bn-BD', |
||||
gu: 'gu-IN', |
||||
kn: 'kn-IN', |
||||
ml: 'ml-IN', |
||||
pa: 'pa-IN', |
||||
or: 'or-IN', |
||||
as: 'as-IN', |
||||
ne: 'ne-NP', |
||||
si: 'si-LK', |
||||
lo: 'lo-LA', |
||||
km: 'km-KH', |
||||
my: 'my-MM', |
||||
ka: 'ka-GE', |
||||
hy: 'hy-AM', |
||||
az: 'az', |
||||
kk: 'kk-KZ', |
||||
mn: 'mn-MN', |
||||
af: 'af', |
||||
sw: 'sw', |
||||
zu: 'zu', |
||||
xh: 'xh', |
||||
yo: 'yo', |
||||
ig: 'ig', |
||||
ha: 'ha', |
||||
so: 'so-SO', |
||||
am: 'am', |
||||
ti: 'ti', |
||||
om: 'om-ET', |
||||
sn: 'sn-ZW', |
||||
rw: 'rw', |
||||
mg: 'mg-MG', |
||||
ny: 'ny-MW', |
||||
eo: 'eo', |
||||
lb: 'lb', |
||||
br: 'br-FR', |
||||
cy: 'cy-GB', |
||||
ga: 'ga-IE', |
||||
gd: 'gd-GB', |
||||
mt: 'mt-MT', |
||||
is: 'is-IS', |
||||
fo: 'fo-FO', |
||||
tk: 'tk-TM', |
||||
uz: 'uz-UZ', |
||||
ky: 'ky-KG', |
||||
tg: 'tg-TJ', |
||||
ps: 'ps-AF', |
||||
sd: 'sd-IN', |
||||
ur: 'ur-PK', |
||||
ckb: 'ckb-IQ', |
||||
ku: 'kmr-Latn', |
||||
yi: 'yi-001', |
||||
jv: 'jv-Latn', |
||||
su: 'su-Latn' |
||||
} |
||||
|
||||
function mapI18nToLt(i18nLanguage: string): string { |
||||
const base = i18nLanguage.split(/[-_]/u)[0]?.toLowerCase() ?? 'en' |
||||
const full = i18nLanguage.replace('_', '-') |
||||
if (LT_ALIASES[full]) return LT_ALIASES[full]! |
||||
if (LT_ALIASES[base]) return LT_ALIASES[base]! |
||||
return 'en-US' |
||||
} |
||||
|
||||
export function buildLanguageToolPreferenceList(i18nLanguage: string | undefined): string[] { |
||||
const primary = mapI18nToLt((i18nLanguage ?? 'en').trim() || 'en') |
||||
const ordered: string[] = [] |
||||
const push = (c: string) => { |
||||
if (!ordered.includes(c)) ordered.push(c) |
||||
} |
||||
push(primary) |
||||
push('en-US') |
||||
push('de-DE') |
||||
const extras = Object.values(LT_ALIASES) |
||||
extras.sort() |
||||
for (const c of extras) { |
||||
push(c) |
||||
} |
||||
return ordered |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
/** |
||||
* Optional plain text used for the next read-aloud instead of deriving text from the event |
||||
* (e.g. after translating in the advanced lab). One-shot per event id. |
||||
*/ |
||||
const overrides = new Map<string, string>() |
||||
|
||||
export function setReadAloudTranslationForEvent(eventId: string, plainText: string): void { |
||||
overrides.set(eventId, plainText) |
||||
} |
||||
|
||||
export function takeReadAloudTranslationForEvent(eventId: string): string | undefined { |
||||
const v = overrides.get(eventId) |
||||
if (v !== undefined) { |
||||
overrides.delete(eventId) |
||||
return v |
||||
} |
||||
return undefined |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap' |
||||
|
||||
describe('plainTextToTipTapDoc', () => { |
||||
it('round-trips simple lines', () => { |
||||
const plain = 'hello\nworld' |
||||
const doc = plainTextToTipTapDoc(plain) |
||||
expect(parseEditorJsonToText(doc).trim()).toBe(plain) |
||||
}) |
||||
|
||||
it('handles empty string', () => { |
||||
const doc = plainTextToTipTapDoc('') |
||||
expect(parseEditorJsonToText(doc).trim()).toBe('') |
||||
}) |
||||
|
||||
it('handles blank line between paragraphs', () => { |
||||
const plain = 'a\n\nb' |
||||
const doc = plainTextToTipTapDoc(plain) |
||||
expect(parseEditorJsonToText(doc).trim()).toBe(plain) |
||||
}) |
||||
|
||||
it('normalizes CRLF', () => { |
||||
const doc = plainTextToTipTapDoc('x\r\ny') |
||||
expect(parseEditorJsonToText(doc).trim()).toBe('x\ny') |
||||
}) |
||||
}) |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
import { TRANSLATE_URL } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
import { sha256 } from '@noble/hashes/sha256' |
||||
import { bytesToHex } from '@noble/hashes/utils' |
||||
|
||||
const memoryCache = new Map<string, { text: string; at: number }>() |
||||
const MAX_MEMORY = 80 |
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24 |
||||
|
||||
function cacheKey(source: string, sourceLang: string, targetLang: string): string { |
||||
const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`))) |
||||
return h |
||||
} |
||||
|
||||
function pruneMemory(): void { |
||||
const now = Date.now() |
||||
for (const [k, v] of memoryCache) { |
||||
if (now - v.at > CACHE_TTL_MS) memoryCache.delete(k) |
||||
} |
||||
while (memoryCache.size > MAX_MEMORY) { |
||||
const first = memoryCache.keys().next().value |
||||
if (first) memoryCache.delete(first) |
||||
else break |
||||
} |
||||
} |
||||
|
||||
export function isTranslateConfigured(): boolean { |
||||
return Boolean(TRANSLATE_URL.trim()) |
||||
} |
||||
|
||||
export async function translatePlainText( |
||||
text: string, |
||||
targetLang: string, |
||||
sourceLang: string = 'auto' |
||||
): Promise<string> { |
||||
const base = TRANSLATE_URL.trim().replace(/\/$/u, '') |
||||
if (!base) { |
||||
throw new Error('Translation URL not configured') |
||||
} |
||||
const key = cacheKey(text, sourceLang, targetLang) |
||||
const hit = memoryCache.get(key) |
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) { |
||||
return hit.text |
||||
} |
||||
|
||||
const url = `${base}/translate` |
||||
const res = await fetch(url, { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ |
||||
q: text, |
||||
source: sourceLang, |
||||
target: targetLang, |
||||
format: 'text' |
||||
}) |
||||
}) |
||||
if (!res.ok) { |
||||
const err = await res.text().catch(() => '') |
||||
logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) }) |
||||
throw new Error(`Translate: ${res.status}`) |
||||
} |
||||
const data = (await res.json()) as { translatedText?: string } |
||||
const out = data.translatedText ?? '' |
||||
pruneMemory() |
||||
memoryCache.set(key, { text: out, at: Date.now() }) |
||||
return out |
||||
} |
||||
Loading…
Reference in new issue