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 @@ -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–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`)
@ -355,6 +355,18 @@ docker build \ @@ -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:

8
docker-compose.dev.yml

@ -16,6 +16,10 @@ services: @@ -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: @@ -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

6
docker-compose.prod.yml

@ -86,7 +86,8 @@ services: @@ -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: @@ -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:

6
docker-compose.yml

@ -15,6 +15,9 @@ services: @@ -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: @@ -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

1
package.json

@ -18,6 +18,7 @@ @@ -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",

3
scripts/dev-all-local.sh

@ -9,5 +9,6 @@ bash "$ROOT/scripts/ensure-libretranslate-dirs.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

58
scripts/prune-libretranslate-packages.py

@ -0,0 +1,58 @@ @@ -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 @@ @@ -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' @@ -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' @@ -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({ @@ -221,6 +224,9 @@ export default function AdvancedEventLabDialog({
[i18nLanguage, i18n.language]
)
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')
useEffect(() => {
@ -229,6 +235,37 @@ export default function AdvancedEventLabDialog({ @@ -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({ @@ -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({ @@ -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({ @@ -434,12 +476,10 @@ export default function AdvancedEventLabDialog({
<DialogContent
overlayClassName="z-[205]"
className={cnDialogShell()}
aria-describedby={undefined}
>
<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">
@ -462,20 +502,71 @@ export default function AdvancedEventLabDialog({ @@ -462,20 +502,71 @@ export default function AdvancedEventLabDialog({
</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 className="flex flex-col gap-2 min-w-0">
{translateLoad === 'idle' || translateLoad === 'loading' ? (
<p className="text-xs text-muted-foreground">{t('Advanced lab translation languages loading')}</p>
) : null}
{translateLoad === 'ready' ? (
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1 min-w-[10rem]">
<Label htmlFor="tr-src">{t('Advanced lab translation source')}</Label>
<Select
value={translateSource}
onValueChange={(v) => {
setTranslateSource(v)
if (v !== 'auto' && v === translateTarget) {
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>
) : null}
{contextEventId && isTranslateConfigured() ? (

8
src/i18n/locales/de.ts

@ -955,8 +955,6 @@ export default { @@ -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 { @@ -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)',

8
src/i18n/locales/en.ts

@ -956,8 +956,6 @@ export default { @@ -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 { @@ -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)',

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

@ -2,6 +2,9 @@ import { linter, type Diagnostic } from '@codemirror/lint' @@ -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 @@ -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<Diagnostic[]>((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

4
src/lib/relay-list-sanitize.ts

@ -3,8 +3,8 @@ import type { TRelayList } from '@/types' @@ -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 => {

90
src/lib/translate-client.ts

@ -28,6 +28,82 @@ export function isTranslateConfigured(): boolean { @@ -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<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(
text: string,
targetLang: string,
@ -37,7 +113,10 @@ export async function translatePlainText( @@ -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( @@ -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 ?? ''

8
src/services/client.service.ts

@ -3520,12 +3520,14 @@ class ClientService extends EventTarget { @@ -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 { @@ -3569,10 +3571,6 @@ class ClientService extends EventTarget {
})
}
if (!isOwnRelayList) {
return mergeKind10243(stripLocalNetworkRelaysFromRelayList(relayList))
}
return mergeKind10243(relayList)
})
}

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

@ -8,6 +8,7 @@ import { TRelaySet, TRelayList } from '@/types' @@ -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 { @@ -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

Loading…
Cancel
Save