29 changed files with 1452 additions and 19 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
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 @@ |
|||||||
|
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