Browse Source

bug-fix local build

imwald
Silberengel 2 weeks ago
parent
commit
8de4d34bd4
  1. 14
      PROXY_SETUP.md
  2. 8
      docker-compose.dev.yml
  3. 6
      docker-compose.prod.yml
  4. 6
      docker-compose.yml
  5. 1
      package.json
  6. 3
      scripts/dev-all-local.sh
  7. 58
      scripts/prune-libretranslate-packages.py
  8. 40
      scripts/prune-libretranslate-packages.sh
  9. 135
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  10. 8
      src/i18n/locales/de.ts
  11. 8
      src/i18n/locales/en.ts
  12. 50
      src/lib/languagetool-cm-linter.ts
  13. 4
      src/lib/relay-list-sanitize.ts
  14. 90
      src/lib/translate-client.ts
  15. 8
      src/services/client.service.ts
  16. 5
      src/services/relay-selection.service.ts

14
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`. **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–2GiB** 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–2GiB** 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`) ## LibreTranslate (same-origin `/api/translate`)
@ -355,6 +355,18 @@ docker build \
## Troubleshooting ## 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 Proxy Returns Imwald HTML Instead of Requested Site
If you've set `ProxyPreserveHost Off` but still get Imwald HTML, test the proxy server directly: If you've set `ProxyPreserveHost Off` but still get Imwald HTML, test the proxy server directly:

8
docker-compose.dev.yml

@ -16,6 +16,10 @@ services:
og-proxy: og-proxy:
image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy}
container_name: jumble-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: ports:
- '127.0.0.1:8090:8090' - '127.0.0.1:8090:8090'
environment: 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. # Without LT_LOAD_ONLY the image downloads many GB of models before binding :5000 — Vite then gets ECONNRESET on /translate.
tty: true tty: true
environment: 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: volumes:
- ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/share:/home/libretranslate/.local/share
- ./.local-libretranslate/cache:/home/libretranslate/.local/cache - ./.local-libretranslate/cache:/home/libretranslate/.local/cache

6
docker-compose.prod.yml

@ -86,7 +86,8 @@ services:
- '127.0.0.1:5000:5000' - '127.0.0.1:5000:5000'
tty: true tty: true
environment: 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: volumes:
- ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/share:/home/libretranslate/.local/share
- ./.local-libretranslate/cache:/home/libretranslate/.local/cache - ./.local-libretranslate/cache:/home/libretranslate/.local/cache
@ -102,6 +103,9 @@ services:
image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy}
profiles: ['stack'] profiles: ['stack']
container_name: og-proxy container_name: og-proxy
dns:
- 1.1.1.1
- 8.8.8.8
ports: ports:
- '127.0.0.1:8090:8090' - '127.0.0.1:8090:8090'
environment: environment:

6
docker-compose.yml

@ -15,6 +15,9 @@ services:
og-proxy: og-proxy:
image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy} image: ${OG_PROXY_IMAGE:-silberengel/wikistr:latest-og-proxy}
container_name: jumble-og-proxy container_name: jumble-og-proxy
dns:
- 1.1.1.1
- 8.8.8.8
ports: ports:
- '127.0.0.1:8090:8090' - '127.0.0.1:8090:8090'
environment: environment:
@ -47,7 +50,8 @@ services:
- '5000:5000' - '5000:5000'
tty: true tty: true
environment: 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: volumes:
- ./.local-libretranslate/share:/home/libretranslate/.local/share - ./.local-libretranslate/share:/home/libretranslate/.local/share
- ./.local-libretranslate/cache:/home/libretranslate/.local/cache - ./.local-libretranslate/cache:/home/libretranslate/.local/cache

1
package.json

@ -18,6 +18,7 @@
"stack:remote": "bash scripts/stack-remote.sh", "stack:remote": "bash scripts/stack-remote.sh",
"dev:refresh": "rm -rf node_modules/.vite && vite --host", "dev:refresh": "rm -rf node_modules/.vite && vite --host",
"docker:prep-libretranslate": "bash scripts/ensure-libretranslate-dirs.sh", "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: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", "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", "piper-tts-proxy": "cross-env NODE_ENV=development npx --yes tsx services/piper-tts-proxy/http.ts",

3
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 build piper-tts-proxy
docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d \ docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d \
og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy 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 exec npm run dev

58
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)")

40
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

135
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -2,12 +2,10 @@ import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
Select, Select,
@ -20,7 +18,12 @@ import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension } from '@/lib/languagetool-cm-linter' import { languageToolLintExtension } from '@/lib/languagetool-cm-linter'
import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' 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 { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
@ -221,6 +224,9 @@ export default function AdvancedEventLabDialog({
[i18nLanguage, i18n.language] [i18nLanguage, i18n.language]
) )
const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US')
const [translateLangs, setTranslateLangs] = useState<TranslateLanguageOption[]>([])
const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle')
const [translateSource, setTranslateSource] = useState('auto')
const [translateTarget, setTranslateTarget] = useState('en') const [translateTarget, setTranslateTarget] = useState('en')
useEffect(() => { useEffect(() => {
@ -229,6 +235,37 @@ export default function AdvancedEventLabDialog({
} }
}, [open, ltList]) }, [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(() => { const destroyEditors = useCallback(() => {
if (bodyApiRef) bodyApiRef.current = null if (bodyApiRef) bodyApiRef.current = null
markupView.current?.destroy() markupView.current?.destroy()
@ -299,7 +336,7 @@ export default function AdvancedEventLabDialog({
}) })
] ]
if (isLanguageToolConfigured()) { if (isLanguageToolConfigured()) {
mkExtensions.push(languageToolLintExtension(ltLang, 450)) mkExtensions.push(languageToolLintExtension(ltLang, 650))
} }
if (dark) mkExtensions.push(oneDark) if (dark) mkExtensions.push(oneDark)
@ -407,8 +444,13 @@ export default function AdvancedEventLabDialog({
} }
const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? '' const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? ''
if (!text.trim()) return 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 { try {
const out = await translatePlainText(text, translateTarget.trim() || 'en') const out = await translatePlainText(text, translateTarget, translateSource)
if (!markupView.current) return if (!markupView.current) return
markupView.current.dispatch({ markupView.current.dispatch({
changes: { from: 0, to: markupView.current.state.doc.length, insert: out } changes: { from: 0, to: markupView.current.state.doc.length, insert: out }
@ -434,12 +476,10 @@ export default function AdvancedEventLabDialog({
<DialogContent <DialogContent
overlayClassName="z-[205]" overlayClassName="z-[205]"
className={cnDialogShell()} className={cnDialogShell()}
aria-describedby={undefined}
> >
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b"> <DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b">
<DialogTitle>{t('Advanced event lab')}</DialogTitle> <DialogTitle>{t('Advanced event lab')}</DialogTitle>
<DialogDescription className="text-left">
{t('Advanced lab hint')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2 px-4 py-2 border-b shrink-0 flex-wrap"> <div className="flex flex-col gap-2 px-4 py-2 border-b shrink-0 flex-wrap">
@ -462,20 +502,71 @@ export default function AdvancedEventLabDialog({
</div> </div>
) : null} ) : null}
{isTranslateConfigured() ? ( {isTranslateConfigured() ? (
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-col gap-2 min-w-0">
<div className="space-y-1"> {translateLoad === 'idle' || translateLoad === 'loading' ? (
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label> <p className="text-xs text-muted-foreground">{t('Advanced lab translation languages loading')}</p>
<Input ) : null}
id="tr-tgt" {translateLoad === 'ready' ? (
className="w-24 font-mono text-sm" <div className="flex flex-wrap items-end gap-3">
value={translateTarget} <div className="space-y-1 min-w-[10rem]">
onChange={(e) => setTranslateTarget(e.target.value)} <Label htmlFor="tr-src">{t('Advanced lab translation source')}</Label>
placeholder="en" <Select
/> value={translateSource}
</div> onValueChange={(v) => {
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}> setTranslateSource(v)
{t('Advanced lab translate')} if (v !== 'auto' && v === translateTarget) {
</Button> const alt = translateLangs.find((l) => l.code !== v)?.code
if (alt) setTranslateTarget(alt)
}
}}
>
<SelectTrigger id="tr-src" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
{translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name} ({l.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 min-w-[10rem]">
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label>
<Select
value={translateTarget}
onValueChange={(v) => {
setTranslateTarget(v)
if (translateSource !== 'auto' && v === translateSource) {
setTranslateSource('auto')
}
}}
>
<SelectTrigger id="tr-tgt" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
{translateLangs.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name} ({l.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}>
{t('Advanced lab translate')}
</Button>
</div>
) : null}
{translateLoad === 'empty' ? (
<p className="text-xs text-destructive">{t('Advanced lab translation languages empty')}</p>
) : null}
{translateLoad === 'error' ? (
<p className="text-xs text-destructive">{t('Advanced lab translation languages error')}</p>
) : null}
</div> </div>
) : null} ) : null}
{contextEventId && isTranslateConfigured() ? ( {contextEventId && isTranslateConfigured() ? (

8
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.', '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.', 'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.',
'Advanced event lab': 'Erweiterter Editor', '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 applyError': 'Editor ist nicht bereit. Bitte erneut versuchen.',
'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen', 'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen',
'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label markdown': 'Markdown',
@ -967,7 +965,13 @@ export default {
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Sprache für Grammatikprüfung', 'Advanced lab grammar language': 'Sprache für Grammatikprüfung',
'Advanced lab translate': 'Text übersetzen', '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 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 not configured': 'Übersetzungs-URL ist nicht gesetzt (VITE_TRANSLATE_URL).',
'Advanced lab translate done': 'Übersetzung wurde in den Editor eingefügt.', 'Advanced lab translate done': 'Übersetzung wurde in den Editor eingefügt.',
'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)', 'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)',

8
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.', '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.', 'Use filter hint': 'Only the kinds you select below are requested and shown.',
'Advanced event lab': 'Advanced editor', '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 applyError': 'Editor is not ready. Try again.',
'Advanced lab cancel undo': 'Cancel and Undo Changes', 'Advanced lab cancel undo': 'Cancel and Undo Changes',
'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label markdown': 'Markdown',
@ -968,7 +966,13 @@ export default {
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Grammar check language', 'Advanced lab grammar language': 'Grammar check language',
'Advanced lab translate': 'Translate body', '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 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 not configured': 'Translation URL is not set (VITE_TRANSLATE_URL).',
'Advanced lab translate done': 'Translation inserted into the editor.', 'Advanced lab translate done': 'Translation inserted into the editor.',
'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)', 'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)',

50
src/lib/languagetool-cm-linter.ts

@ -2,6 +2,9 @@ import { linter, type Diagnostic } from '@codemirror/lint'
import type { Extension } from '@codemirror/state' import type { Extension } from '@codemirror/state'
import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' 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 { function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null {
const from = Math.max(0, Math.min(m.offset, docLen)) const from = Math.max(0, Math.min(m.offset, docLen))
const to = Math.max(from, Math.min(m.offset + m.length, 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`. * 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( export function languageToolLintExtension(language: string, debounceMs: number): Extension {
language: string, let requestSeq = 0
debounceMs: number let inFlight: AbortController | null = null
): Extension {
return linter((view) => { return linter((view) => {
return new Promise<Diagnostic[]>((resolve) => { return new Promise<Diagnostic[]>((resolve) => {
let settled = false
const finish = (diags: Diagnostic[]) => {
if (settled) return
settled = true
resolve(diags)
}
const text = view.state.doc.toString() const text = view.state.doc.toString()
if (text.length < 3) { if (text.length < 3) {
resolve([]) finish([])
return return
} }
const seq = ++requestSeq const seq = ++requestSeq
inFlight?.abort()
inFlight = null
window.setTimeout(() => { window.setTimeout(() => {
if (seq !== requestSeq) return if (seq !== requestSeq) {
void languageToolCheck(text, language) 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) => { .then((res) => {
if (seq !== requestSeq) return if (seq !== requestSeq) {
finish([])
return
}
const docLen = view.state.doc.length const docLen = view.state.doc.length
const out: Diagnostic[] = [] const out: Diagnostic[] = []
for (const m of res.matches ?? []) { for (const m of res.matches ?? []) {
const d = matchToDiagnostic(docLen, m) const d = matchToDiagnostic(docLen, m)
if (d) out.push(d) if (d) out.push(d)
} }
resolve(out) finish(out)
})
.catch(() => {
finish([])
}) })
.catch(() => resolve([]))
}, debounceMs) }, debounceMs)
}) })
}) })
} }
let requestSeq = 0

4
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). * 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; * Apply to **kind 10002** (NIP-65): those URLs belong on kind 10432 (cache relays), not read/write outbox/inbox.
* the viewer's own list should not be passed through this (they may use local cache relays). * Still use when merging **another user's** 10002 so we never open their LAN relays.
*/ */
export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList { export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList {
const keepUrl = (u: string): boolean => { const keepUrl = (u: string): boolean => {

90
src/lib/translate-client.ts

@ -28,6 +28,82 @@ export function isTranslateConfigured(): boolean {
return Boolean(TRANSLATE_URL.trim()) return Boolean(TRANSLATE_URL.trim())
} }
/** LibreTranslate uses ISO 639-1; map a few common mistypes (defence in depth). */
const LANG_ALIASES: Record<string, string> = {
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<string>()
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<TranslateLanguageOption[]> {
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( export async function translatePlainText(
text: string, text: string,
targetLang: string, targetLang: string,
@ -37,7 +113,10 @@ export async function translatePlainText(
if (!base) { if (!base) {
throw new Error('Translation URL not configured') 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) const hit = memoryCache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL_MS) { if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
return hit.text return hit.text
@ -49,15 +128,18 @@ export async function translatePlainText(
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
q: text, q: text,
source: sourceLang, source: resolvedSource,
target: targetLang, target: resolvedTarget,
format: 'text' format: 'text'
}) })
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.text().catch(() => '') const err = await res.text().catch(() => '')
logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) }) 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 data = (await res.json()) as { translatedText?: string }
const out = data.translatedText ?? '' const out = data.translatedText ?? ''

8
src/services/client.service.ts

@ -3520,12 +3520,14 @@ class ClientService extends EventTarget {
return { ...list, httpRead: h.httpRead, httpWrite: h.httpWrite, httpOriginalRelays: h.httpOriginalRelays } return { ...list, httpRead: h.httpRead, httpWrite: h.httpWrite, httpOriginalRelays: h.httpOriginalRelays }
} }
const relayList = relayEvent ? getRelayListFromEvent(relayEvent) : { const relayListFrom10002 = relayEvent ? getRelayListFromEvent(relayEvent) : {
write: [], write: [],
read: [], read: [],
originalRelays: [], originalRelays: [],
...emptyHttp ...emptyHttp
} }
// LAN / loopback belong on kind 10432 (cache), not NIP-65 10002 — strip 10002 before merging cache.
const relayList = stripLocalNetworkRelaysFromRelayList(relayListFrom10002)
if (isOwnRelayList && cacheEvent) { if (isOwnRelayList && cacheEvent) {
const cacheRelayList = getRelayListFromEvent(cacheEvent) const cacheRelayList = getRelayListFromEvent(cacheEvent)
@ -3569,10 +3571,6 @@ class ClientService extends EventTarget {
}) })
} }
if (!isOwnRelayList) {
return mergeKind10243(stripLocalNetworkRelaysFromRelayList(relayList))
}
return mergeKind10243(relayList) return mergeKind10243(relayList)
}) })
} }

5
src/services/relay-selection.service.ts

@ -8,6 +8,7 @@ import { TRelaySet, TRelayList } from '@/types'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
@ -233,7 +234,9 @@ class RelaySelectionService {
}) })
} }
} else { } else {
relayList = mergeKind10243(getRelayListFromEvent(relayListEvent)) relayList = mergeKind10243(
stripLocalNetworkRelaysFromRelayList(getRelayListFromEvent(relayListEvent))
)
} }
// Merge cache relays (kind 10432) into the relay list // Merge cache relays (kind 10432) into the relay list

Loading…
Cancel
Save