diff --git a/PROXY_SETUP.md b/PROXY_SETUP.md index f5f9ffb1..bc95ea85 100644 --- a/PROXY_SETUP.md +++ b/PROXY_SETUP.md @@ -187,7 +187,7 @@ VITE_TRANSLATE_URL=/api/translate **Production:** `docker compose -f docker-compose.prod.yml --profile editor-tools up -d languagetool libretranslate` publishes **127.0.0.1:8010** and **127.0.0.1:5000** (loopback-only). Proxy those paths from Apache/nginx to the SPA origin, and bake the client with `LANGUAGE_TOOL_URL=/api/languagetool` and `TRANSLATE_URL=/api/translate` when running `./scripts/build-and-push-prod.sh`. -**Notes:** LanguageTool’s JVM image often needs **~1–2 GiB** RAM. LibreTranslate **does not listen on port 5000 until models are ready**; without **`LT_LOAD_ONLY`** it may pull **many gigabytes** first, so the Vite proxy can show **`ECONNRESET` on `/translate`** while booting. Compose sets **`LT_LOAD_ONLY=en,de`** by default (override with **`LT_LOAD_ONLY`**). Models are stored under **`.local-libretranslate/share`** and **`.local-libretranslate/cache`** (gitignored) with **bind mounts** so they survive **`docker compose down`**, image updates, and container recreate. **`scripts/ensure-libretranslate-dirs.sh`** (run automatically by **`npm run dev:all`**, **`npm run stack:remote`**, **`npm run docker:editor-tools`**, etc.) creates those dirs and **`chown`s them to UID 1032** via a short **Alpine** container so the LibreTranslate user can write. If you start **`libretranslate` by hand**, run **`npm run docker:prep-libretranslate`** once first. First download can still take **several minutes**; use **`docker logs -f jumble-libretranslate`** until **`curl http://127.0.0.1:5000/languages`** returns JSON. +**Notes:** LanguageTool’s JVM image often needs **~1–2 GiB** RAM. LibreTranslate **does not listen on port 5000 until models are ready**; without **`LT_LOAD_ONLY`** it may pull **many gigabytes** first, so the Vite proxy can show **`ECONNRESET` on `/translate`** while booting. Compose defaults **`LT_LOAD_ONLY`** to **ten** widely used codes (**en, de, es, fr, it, pt, ru, zh, ja, ar** — see `libretranslate` in `docker-compose.dev.yml`). Override with **`LT_LOAD_ONLY`** to add or remove codes; first start downloads packs for every listed code. **`LT_UPDATE_MODELS`** defaults to **`true`** so if you **expand** `LT_LOAD_ONLY` later, a **recreated** container still **installs missing** Argos packages into the bind-mounted `.local-libretranslate` tree (otherwise an older en/de-only cache sticks). Set **`LT_UPDATE_MODELS=false`** after everything is installed if you want faster routine restarts. Models are stored under **`.local-libretranslate/share`** and **`.local-libretranslate/cache`** (gitignored) with **bind mounts** so they survive **`docker compose down`**, image updates, and container recreate. **`scripts/ensure-libretranslate-dirs.sh`** (run automatically by **`npm run dev:all`**, **`npm run stack:remote`**, **`npm run docker:editor-tools`**, etc.) creates those dirs and **`chown`s them to UID 1032** via a short **Alpine** container so the LibreTranslate user can write. If you start **`libretranslate` by hand**, run **`npm run docker:prep-libretranslate`** once first. First download can still take **several minutes**; use **`docker logs -f jumble-libretranslate`** until **`curl http://127.0.0.1:5000/languages`** returns JSON. If logs show **`Cannot update models`** / **`Unavailable language codes: …`**, one bad token in **`LT_LOAD_ONLY` aborts the whole install** (you stay on whatever was already on disk, often en/de only). **Norwegian** must be **`nb`** (Bokmål), not ISO **`no`**. After you shrink **`LT_LOAD_ONLY`**, run **`npm run docker:prune-libretranslate-packages`** to remove leftover Argos package dirs under **`.local-libretranslate/share/argos-translate/packages`** (and unused **MiniSBD** `.onnx` files); the script briefly stops **`jumble-libretranslate`** or **`imwald-libretranslate`**. Override codes with **`LT_LOAD_ONLY=…`** on the same command if they differ from the compose default. ## LibreTranslate (same-origin `/api/translate`) @@ -355,6 +355,18 @@ docker build \ ## Troubleshooting +### OG proxy (`docker logs …og-proxy`): `fetch failed` / `Retryable error` + +The **wikistr** image uses Node **`fetch`** to load the target URL. Inside Docker, transient DNS failures often show up as **`TypeError: fetch failed`** with cause **`getaddrinfo EAI_AGAIN`** (the proxy then retries and can spam logs). Compose sets **`dns: [1.1.1.1, 8.8.8.8]`** on **`og-proxy`** so lookups do not rely only on Docker’s internal resolver. If your network blocks third-party DNS, remove or override that **`dns`** block (compose override file or forked image). + +Quick checks: + +```bash +docker exec jumble-og-proxy getent hosts example.com +docker exec jumble-og-proxy node -e "fetch('https://example.com').then(r=>console.log('HTTP',r.status)).catch(e=>console.error(e.cause||e))" +curl -sS -o /dev/null -w '%{http_code}\n' -H 'Origin: http://localhost:5173' 'http://127.0.0.1:8090/sites/?url=https%3A%2F%2Fexample.com' +``` + ### If Proxy Returns Imwald HTML Instead of Requested Site If you've set `ProxyPreserveHost Off` but still get Imwald HTML, test the proxy server directly: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c2609e31..1863dd49 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,6 +16,10 @@ services: og-proxy: image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} container_name: jumble-og-proxy + # Docker’s resolver sometimes returns EAI_AGAIN → Node "fetch failed" and noisy retries in proxy logs. + dns: + - 1.1.1.1 + - 8.8.8.8 ports: - '127.0.0.1:8090:8090' environment: @@ -64,7 +68,9 @@ services: # Without LT_LOAD_ONLY the image downloads many GB of models before binding :5000 — Vite then gets ECONNRESET on /translate. tty: true environment: - LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de} + LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar} + # Install missing packs when `LT_LOAD_ONLY` grows (persisted volume otherwise keeps old en/de-only index). + LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} volumes: - ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/cache:/home/libretranslate/.local/cache diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e7e1ca27..643e78ef 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -86,7 +86,8 @@ services: - '127.0.0.1:5000:5000' tty: true environment: - LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de} + LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar} + LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} volumes: - ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/cache:/home/libretranslate/.local/cache @@ -102,6 +103,9 @@ services: image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} profiles: ['stack'] container_name: og-proxy + dns: + - 1.1.1.1 + - 8.8.8.8 ports: - '127.0.0.1:8090:8090' environment: diff --git a/docker-compose.yml b/docker-compose.yml index de496714..f77a7956 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: og-proxy: image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} container_name: jumble-og-proxy + dns: + - 1.1.1.1 + - 8.8.8.8 ports: - '127.0.0.1:8090:8090' environment: @@ -47,7 +50,8 @@ services: - '5000:5000' tty: true environment: - LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de} + LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar} + LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} volumes: - ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/cache:/home/libretranslate/.local/cache diff --git a/package.json b/package.json index 58468637..935c5fe5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "stack:remote": "bash scripts/stack-remote.sh", "dev:refresh": "rm -rf node_modules/.vite && vite --host", "docker:prep-libretranslate": "bash scripts/ensure-libretranslate-dirs.sh", + "docker:prune-libretranslate-packages": "bash scripts/prune-libretranslate-packages.sh", "docker:editor-tools": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate", "docker:local-ancillary": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts build piper-tts-proxy && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy", "piper-tts-proxy": "cross-env NODE_ENV=development npx --yes tsx services/piper-tts-proxy/http.ts", diff --git a/scripts/dev-all-local.sh b/scripts/dev-all-local.sh index 4829f49f..d51d49f7 100644 --- a/scripts/dev-all-local.sh +++ b/scripts/dev-all-local.sh @@ -9,5 +9,6 @@ bash "$ROOT/scripts/ensure-libretranslate-dirs.sh" docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts build piper-tts-proxy docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d \ og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy -echo "[dev:all] Jumble=Vite (.env.development → /sites, /api/piper-tts, lab APIs) | og-proxy :8090 | Piper :9876" +echo "[dev:all] Jumble=Vite (.env.development → /sites→:8090 og-proxy, /api/* lab & Piper) | Piper HTTP :9876" +echo "[dev:all] If Firefox logs ws://localhost:4869 failures, remove that URL from Settings → relays (or start your local cache/index relay there) — it is not the OG proxy." exec npm run dev diff --git a/scripts/prune-libretranslate-packages.py b/scripts/prune-libretranslate-packages.py new file mode 100755 index 00000000..986a65c2 --- /dev/null +++ b/scripts/prune-libretranslate-packages.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Remove Argos dirs under /argos that are not needed for LT_LOAD_ONLY (host mounts this to .../share/argos-translate).""" +import os +import re +import shutil +import sys + +DEFAULT = "en,de,es,fr,it,pt,ru,zh,ja,ar" +allowed = set(os.environ.get("LT_LOAD_ONLY", DEFAULT).replace(" ", "").split(",")) +if not allowed or allowed == {""}: + print("LT_LOAD_ONLY empty", file=sys.stderr) + sys.exit(1) + +argos = os.environ.get("ARGOS_ROOT", "/argos") +pkgs = os.path.join(argos, "packages") +msbd = os.path.join(argos, "minisbd") + + +def pair_from_dirname(name: str) -> tuple[str, str] | None: + m = re.match(r"^([a-z]{2})_([a-z]{2})$", name) + if m: + return m.group(1), m.group(2) + m = re.match(r"^translate-([a-z]{2})_([a-z]{2})-", name) + if m: + return m.group(1), m.group(2) + return None + + +removed = 0 +if os.path.isdir(pkgs): + for name in sorted(os.listdir(pkgs)): + path = os.path.join(pkgs, name) + if not os.path.isdir(path): + continue + p = pair_from_dirname(name) + if p is None: + print(f"skip (unrecognized name): {name}") + continue + f, t = p + if f in allowed and t in allowed: + continue + print(f"remove package: {name}") + shutil.rmtree(path) + removed += 1 + +if os.path.isdir(msbd): + for fn in sorted(os.listdir(msbd)): + if not fn.endswith(".onnx"): + continue + code = fn[:-5] + if code in allowed: + continue + p = os.path.join(msbd, fn) + print(f"remove minisbd: {fn}") + os.remove(p) + removed += 1 + +print(f"done ({removed} removed)") diff --git a/scripts/prune-libretranslate-packages.sh b/scripts/prune-libretranslate-packages.sh new file mode 100755 index 00000000..9f8c81fd --- /dev/null +++ b/scripts/prune-libretranslate-packages.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Drop Argos packages / MiniSBD files not in LT_LOAD_ONLY (default matches docker-compose libretranslate). +# Stops LibreTranslate briefly so files are not in use; uses Docker+Alpine to delete UID-1032-owned trees. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ARGOS="$ROOT/.local-libretranslate/share/argos-translate" +LT_LOAD_ONLY="${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar}" + +if [[ ! -d "$ARGOS/packages" ]]; then + echo "Nothing to prune (missing $ARGOS/packages)." >&2 + exit 0 +fi + +running="" +for n in jumble-libretranslate imwald-libretranslate; do + if docker inspect -f '{{.State.Running}}' "$n" &>/dev/null; then + if [[ $(docker inspect -f '{{.State.Running}}' "$n") == "true" ]]; then + running="$n" + break + fi + fi +done + +if [[ -n "$running" ]]; then + echo "Stopping $running ..." + docker stop "$running" >/dev/null +fi + +docker run --rm \ + -e LT_LOAD_ONLY="$LT_LOAD_ONLY" \ + -e ARGOS_ROOT=/argos \ + -v "$ARGOS:/argos:rw" \ + -v "$ROOT/scripts/prune-libretranslate-packages.py:/prune.py:ro" \ + alpine:3.20 \ + sh -ec 'apk add --no-cache python3 >/dev/null && python3 /prune.py' + +if [[ -n "$running" ]]; then + echo "Starting $running ..." + docker start "$running" >/dev/null +fi diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index c6485485..63987368 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -2,12 +2,10 @@ 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, @@ -20,7 +18,12 @@ import { isLanguageToolConfigured } from '@/lib/languagetool-client' import { languageToolLintExtension } from '@/lib/languagetool-cm-linter' import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' -import { isTranslateConfigured, translatePlainText } from '@/lib/translate-client' +import { + fetchTranslateLanguages, + isTranslateConfigured, + translatePlainText, + type TranslateLanguageOption +} 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' @@ -221,6 +224,9 @@ export default function AdvancedEventLabDialog({ [i18nLanguage, i18n.language] ) const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') + const [translateLangs, setTranslateLangs] = useState([]) + const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle') + const [translateSource, setTranslateSource] = useState('auto') const [translateTarget, setTranslateTarget] = useState('en') useEffect(() => { @@ -229,6 +235,37 @@ export default function AdvancedEventLabDialog({ } }, [open, ltList]) + useEffect(() => { + if (!open || !isTranslateConfigured()) { + setTranslateLangs([]) + setTranslateLoad('idle') + return + } + let cancelled = false + setTranslateLoad('loading') + void fetchTranslateLanguages() + .then((list) => { + if (cancelled) return + if (!list.length) { + setTranslateLangs([]) + setTranslateLoad('empty') + return + } + setTranslateLangs(list) + setTranslateSource('auto') + const codes = list.map((l) => l.code) + const tgt = codes.includes('en') ? 'en' : codes[0]! + setTranslateTarget(tgt) + setTranslateLoad('ready') + }) + .catch(() => { + if (!cancelled) setTranslateLoad('error') + }) + return () => { + cancelled = true + } + }, [open]) + const destroyEditors = useCallback(() => { if (bodyApiRef) bodyApiRef.current = null markupView.current?.destroy() @@ -299,7 +336,7 @@ export default function AdvancedEventLabDialog({ }) ] if (isLanguageToolConfigured()) { - mkExtensions.push(languageToolLintExtension(ltLang, 450)) + mkExtensions.push(languageToolLintExtension(ltLang, 650)) } if (dark) mkExtensions.push(oneDark) @@ -407,8 +444,13 @@ export default function AdvancedEventLabDialog({ } const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? '' if (!text.trim()) return + if (translateLoad !== 'ready' || translateLangs.length === 0) return + if (translateSource !== 'auto' && translateSource === translateTarget) { + toast.message(t('Advanced lab translation same source target')) + return + } try { - const out = await translatePlainText(text, translateTarget.trim() || 'en') + const out = await translatePlainText(text, translateTarget, translateSource) if (!markupView.current) return markupView.current.dispatch({ changes: { from: 0, to: markupView.current.state.doc.length, insert: out } @@ -434,12 +476,10 @@ export default function AdvancedEventLabDialog({ {t('Advanced event lab')} - - {t('Advanced lab hint')} -
@@ -462,20 +502,71 @@ export default function AdvancedEventLabDialog({
) : null} {isTranslateConfigured() ? ( -
-
- - setTranslateTarget(e.target.value)} - placeholder="en" - /> -
- +
+ {translateLoad === 'idle' || translateLoad === 'loading' ? ( +

{t('Advanced lab translation languages loading')}

+ ) : null} + {translateLoad === 'ready' ? ( +
+
+ + +
+
+ + +
+ +
+ ) : null} + {translateLoad === 'empty' ? ( +

{t('Advanced lab translation languages empty')}

+ ) : null} + {translateLoad === 'error' ? ( +

{t('Advanced lab translation languages error')}

+ ) : null}
) : null} {contextEventId && isTranslateConfigured() ? ( diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b355446a..59a4bfea 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -955,8 +955,6 @@ export default { 'Feed-Anfragen ohne Kind-Filter; alle Event-Arten werden angezeigt (Relay-Limits und andere Regeln gelten weiter). Zum Testen neuer Event-Kinds.', 'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.', 'Advanced event lab': 'Erweiterter Editor', - 'Advanced lab hint': - 'Markup hier bearbeiten. Für Kind, Tags und rohes JSON den Tab „Json“ im Formular unten nutzen und dort „JSON anwenden“.', 'Advanced lab applyError': 'Editor ist nicht bereit. Bitte erneut versuchen.', 'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen', 'Advanced lab markup label markdown': 'Markdown', @@ -967,7 +965,13 @@ export default { 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', 'Advanced lab grammar language': 'Sprache für Grammatikprüfung', 'Advanced lab translate': 'Text übersetzen', + 'Advanced lab translation source': 'Ausgangssprache', + 'Advanced lab translation source auto': 'Automatisch erkennen', 'Advanced lab translation target': 'Zielsprache', + 'Advanced lab translation languages loading': 'Sprachen werden vom Übersetzungsdienst geladen…', + 'Advanced lab translation languages empty': 'Der Übersetzungsdienst liefert keine Sprachen (Docker / LibreTranslate prüfen).', + 'Advanced lab translation languages error': 'Sprachenliste vom Übersetzungsdienst konnte nicht geladen werden.', + 'Advanced lab translation same source target': 'Ausgangs- und Zielsprache müssen sich unterscheiden.', 'Advanced lab translate not configured': 'Übersetzungs-URL ist nicht gesetzt (VITE_TRANSLATE_URL).', 'Advanced lab translate done': 'Übersetzung wurde in den Editor eingefügt.', 'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 44f5a026..b0c89c56 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -956,8 +956,6 @@ export default { 'Feed requests omit kind filters and every kind is shown (still subject to relay limits and other feed rules). For testing new event kinds.', 'Use filter hint': 'Only the kinds you select below are requested and shown.', 'Advanced event lab': 'Advanced editor', - 'Advanced lab hint': - 'Edit markup here. For kind, tags, and raw JSON, use the Json tab in the composer below, then Apply JSON there.', 'Advanced lab applyError': 'Editor is not ready. Try again.', 'Advanced lab cancel undo': 'Cancel and Undo Changes', 'Advanced lab markup label markdown': 'Markdown', @@ -968,7 +966,13 @@ export default { 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', 'Advanced lab grammar language': 'Grammar check language', 'Advanced lab translate': 'Translate body', + 'Advanced lab translation source': 'Source language', + 'Advanced lab translation source auto': 'Detect automatically', 'Advanced lab translation target': 'Target language', + 'Advanced lab translation languages loading': 'Loading languages from translate service…', + '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.', '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/languagetool-cm-linter.ts b/src/lib/languagetool-cm-linter.ts index ecbef212..2f697123 100644 --- a/src/lib/languagetool-cm-linter.ts +++ b/src/lib/languagetool-cm-linter.ts @@ -2,6 +2,9 @@ import { linter, type Diagnostic } from '@codemirror/lint' import type { Extension } from '@codemirror/state' import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' +/** Local LanguageTool is slow on cold JVM; keep payloads bounded (LT has ~20–30k limits anyway). */ +const MAX_CHECK_CHARS = 28_000 + 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)) @@ -27,36 +30,59 @@ function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | n /** * Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`. + * Per-editor state (debounce / abort) lives in the closure so promises always settle and stale fetches are cancelled. */ -export function languageToolLintExtension( - language: string, - debounceMs: number -): Extension { +export function languageToolLintExtension(language: string, debounceMs: number): Extension { + let requestSeq = 0 + let inFlight: AbortController | null = null + return linter((view) => { return new Promise((resolve) => { + let settled = false + const finish = (diags: Diagnostic[]) => { + if (settled) return + settled = true + resolve(diags) + } + const text = view.state.doc.toString() if (text.length < 3) { - resolve([]) + finish([]) return } + const seq = ++requestSeq + inFlight?.abort() + inFlight = null + window.setTimeout(() => { - if (seq !== requestSeq) return - void languageToolCheck(text, language) + if (seq !== requestSeq) { + finish([]) + return + } + + const toSend = text.length > MAX_CHECK_CHARS ? text.slice(0, MAX_CHECK_CHARS) : text + const ac = new AbortController() + inFlight = ac + + void languageToolCheck(toSend, language, ac.signal) .then((res) => { - if (seq !== requestSeq) return + if (seq !== requestSeq) { + finish([]) + 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) + finish(out) + }) + .catch(() => { + finish([]) }) - .catch(() => resolve([])) }, debounceMs) }) }) } - -let requestSeq = 0 diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts index 6ee77e50..6df617e0 100644 --- a/src/lib/relay-list-sanitize.ts +++ b/src/lib/relay-list-sanitize.ts @@ -3,8 +3,8 @@ import type { TRelayList } from '@/types' /** * Remove LAN / loopback relay URLs (e.g. ws://localhost:4869, 192.168.x.x). - * Use for **another user's** NIP-65 list so we never open their private cache relays; - * the viewer's own list should not be passed through this (they may use local cache relays). + * Apply to **kind 10002** (NIP-65): those URLs belong on kind 10432 (cache relays), not read/write outbox/inbox. + * Still use when merging **another user's** 10002 so we never open their LAN relays. */ export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList { const keepUrl = (u: string): boolean => { diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index fb55f53e..dc7592f3 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -28,6 +28,82 @@ export function isTranslateConfigured(): boolean { return Boolean(TRANSLATE_URL.trim()) } +/** LibreTranslate uses ISO 639-1; map a few common mistypes (defence in depth). */ +const LANG_ALIASES: Record = { + sp: 'es', + ger: 'de', + eng: 'en', + fra: 'fr', + ita: 'it', + por: 'pt', + // LibreTranslate/Argos use Bokmål code `nb`; ISO 639-1 `no` is not in the model index. + no: 'nb' +} + +export function normalizeTranslateLangCode(code: string): string { + const t = code.trim().toLowerCase() + return (LANG_ALIASES[t] ?? code.trim()) || 'en' +} + +export type TranslateLanguageOption = { code: string; name: string } + +let languagesCache: { list: TranslateLanguageOption[]; at: number } | null = null +const LANGUAGES_CACHE_TTL_MS = 60_000 + +function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] { + if (!Array.isArray(data)) return [] + const out: TranslateLanguageOption[] = [] + for (const row of data) { + if (typeof row === 'string') { + out.push({ code: row, name: row }) + } else if (row && typeof row === 'object' && 'code' in row) { + const r = row as { code: unknown; name?: unknown } + const code = String(r.code) + const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : code + out.push({ code, name }) + } + } + const seen = new Set() + const dedup = out.filter((o) => { + if (seen.has(o.code)) return false + seen.add(o.code) + return true + }) + dedup.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + return dedup +} + +/** GET `/languages` on the configured translate base (same-origin `/api/translate` in dev). */ +export async function fetchTranslateLanguages(): Promise { + const base = TRANSLATE_URL.trim().replace(/\/$/u, '') + if (!base) return [] + const now = Date.now() + if (languagesCache && now - languagesCache.at < LANGUAGES_CACHE_TTL_MS) { + return languagesCache.list + } + const url = `${base}/languages` + const res = await fetch(url) + if (!res.ok) { + logger.warn('[Translate] /languages failed', { status: res.status }) + languagesCache = null + return [] + } + try { + const data = (await res.json()) as unknown + const list = parseLanguagesResponse(data) + languagesCache = { list, at: now } + return list + } catch (e) { + logger.warn('[Translate] /languages parse error', { e }) + languagesCache = null + return [] + } +} + +export function clearTranslateLanguagesCache(): void { + languagesCache = null +} + export async function translatePlainText( text: string, targetLang: string, @@ -37,7 +113,10 @@ export async function translatePlainText( if (!base) { throw new Error('Translation URL not configured') } - const key = cacheKey(text, sourceLang, targetLang) + const resolvedTarget = normalizeTranslateLangCode(targetLang) + const resolvedSource = + sourceLang === 'auto' ? 'auto' : normalizeTranslateLangCode(sourceLang) + const key = cacheKey(text, resolvedSource, resolvedTarget) const hit = memoryCache.get(key) if (hit && Date.now() - hit.at < CACHE_TTL_MS) { return hit.text @@ -49,15 +128,18 @@ export async function translatePlainText( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q: text, - source: sourceLang, - target: targetLang, + source: resolvedSource, + target: resolvedTarget, 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 detail = err.replace(/\s+/gu, ' ').trim().slice(0, 160) + throw new Error( + detail ? `Translate: ${res.status} — ${detail}` : `Translate: ${res.status}` + ) } const data = (await res.json()) as { translatedText?: string } const out = data.translatedText ?? '' diff --git a/src/services/client.service.ts b/src/services/client.service.ts index c6b19a07..c2cf4b4f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3520,12 +3520,14 @@ class ClientService extends EventTarget { return { ...list, httpRead: h.httpRead, httpWrite: h.httpWrite, httpOriginalRelays: h.httpOriginalRelays } } - const relayList = relayEvent ? getRelayListFromEvent(relayEvent) : { + const relayListFrom10002 = relayEvent ? getRelayListFromEvent(relayEvent) : { write: [], read: [], originalRelays: [], ...emptyHttp } + // LAN / loopback belong on kind 10432 (cache), not NIP-65 10002 — strip 10002 before merging cache. + const relayList = stripLocalNetworkRelaysFromRelayList(relayListFrom10002) if (isOwnRelayList && cacheEvent) { const cacheRelayList = getRelayListFromEvent(cacheEvent) @@ -3569,10 +3571,6 @@ class ClientService extends EventTarget { }) } - if (!isOwnRelayList) { - return mergeKind10243(stripLocalNetworkRelaysFromRelayList(relayList)) - } - return mergeKind10243(relayList) }) } diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index bb9d9c64..d00d240d 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -8,6 +8,7 @@ import { TRelaySet, TRelayList } from '@/types' import logger from '@/lib/logger' import indexedDb from '@/services/indexed-db.service' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' +import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import nip66Service from '@/services/nip66.service' import storage from '@/services/local-storage.service' @@ -233,7 +234,9 @@ class RelaySelectionService { }) } } else { - relayList = mergeKind10243(getRelayListFromEvent(relayListEvent)) + relayList = mergeKind10243( + stripLocalNetworkRelaysFromRelayList(getRelayListFromEvent(relayListEvent)) + ) } // Merge cache relays (kind 10432) into the relay list