Browse Source

add british english to supported languages

imwald
Silberengel 2 weeks ago
parent
commit
7976310232
  1. 2
      PROXY_SETUP.md
  2. 3
      docker-compose.dev.yml
  3. 3
      docker-compose.prod.yml
  4. 3
      docker-compose.yml
  5. 4
      package-lock.json
  6. 2
      package.json
  7. BIN
      scripts/__pycache__/prune-libretranslate-packages.cpython-312.pyc
  8. 55
      scripts/ensure-libretranslate-dirs.sh
  9. 4
      scripts/libretranslate-lt.default.env
  10. 18
      scripts/prune-libretranslate-packages.py
  11. 7
      scripts/prune-libretranslate-packages.sh
  12. 16
      services/piper-tts-proxy/server.ts
  13. 25
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  14. 5
      src/components/NoteOptions/DesktopMenu.tsx
  15. 11
      src/components/NoteOptions/useMenuActions.tsx
  16. 6
      src/components/ui/dropdown-menu.tsx
  17. 4
      src/components/ui/select.tsx
  18. 46
      src/index.css
  19. 44
      src/lib/language-display-meta.test.ts
  20. 73
      src/lib/language-display-meta.ts
  21. 13
      src/lib/languagetool-language-order.test.ts
  22. 2
      src/lib/languagetool-language-order.ts
  23. 13
      src/lib/read-aloud.ts
  24. 58
      src/lib/translate-client.ts
  25. 20
      src/lib/trinity-languages.test.ts
  26. 28
      src/lib/trinity-languages.ts

2
PROXY_SETUP.md

@ -187,7 +187,7 @@ VITE_TRANSLATE_URL=/api/translate
**Production:** `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d` starts the full stack (including LanguageTool on **127.0.0.1:8010** and LibreTranslate on **127.0.0.1:5000**). Run `bash scripts/ensure-libretranslate-dirs.sh` once on the server (LibreTranslate UID **1032** on `.local-libretranslate`, Piper ONNX into `.local-piper-data` and the **`_piper-stack-data`** Docker volume when it exists). Proxy `/api/languagetool` and `/api/translate` from Apache/nginx to those ports, 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 pull && docker compose -f docker-compose.prod.yml up -d` starts the full stack (including LanguageTool on **127.0.0.1:8010** and LibreTranslate on **127.0.0.1:5000**). Run `bash scripts/ensure-libretranslate-dirs.sh` once on the server (LibreTranslate UID **1032** on `.local-libretranslate`, Piper ONNX into `.local-piper-data` and the **`_piper-stack-data`** Docker volume when it exists). Proxy `/api/languagetool` and `/api/translate` from Apache/nginx to those ports, 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 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. **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 loads **`LT_LOAD_ONLY`** from **`scripts/libretranslate-lt.default.env`** (same file is read by **`scripts/ensure-libretranslate-dirs.sh`** and **`scripts/prune-libretranslate-packages.sh`**). Edit that file to add or remove codes, then recreate LibreTranslate; first start downloads packs for every listed code. For a one-off prune without editing the file, run **`export LT_LOAD_ONLY=…`** before **`npm run docker:prune-libretranslate-packages`**. **`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`**.
## LibreTranslate (same-origin `/api/translate`) ## LibreTranslate (same-origin `/api/translate`)

3
docker-compose.dev.yml

@ -67,8 +67,9 @@ services:
- '5000:5000' - '5000:5000'
# 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
env_file:
- ./scripts/libretranslate-lt.default.env
environment: environment:
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). # Install missing packs when `LT_LOAD_ONLY` grows (persisted volume otherwise keeps old en/de-only index).
LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true}
volumes: volumes:

3
docker-compose.prod.yml

@ -83,8 +83,9 @@ services:
ports: ports:
- '127.0.0.1:5000:5000' - '127.0.0.1:5000:5000'
tty: true tty: true
env_file:
- ./scripts/libretranslate-lt.default.env
environment: environment:
LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar}
LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true}
volumes: volumes:
# One tree under .local (same as upstream run.sh -v …/lt-local:/home/libretranslate/.local); avoids split-mount permission edge cases. # One tree under .local (same as upstream run.sh -v …/lt-local:/home/libretranslate/.local); avoids split-mount permission edge cases.

3
docker-compose.yml

@ -49,8 +49,9 @@ services:
ports: ports:
- '5000:5000' - '5000:5000'
tty: true tty: true
env_file:
- ./scripts/libretranslate-lt.default.env
environment: environment:
LT_LOAD_ONLY: ${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar}
LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true} LT_UPDATE_MODELS: ${LT_UPDATE_MODELS:-true}
volumes: volumes:
- ./.local-libretranslate:/home/libretranslate/.local - ./.local-libretranslate:/home/libretranslate/.local

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.0.11", "version": "23.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.0.11", "version": "23.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.0.11", "version": "23.1.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

BIN
scripts/__pycache__/prune-libretranslate-packages.cpython-312.pyc

Binary file not shown.

55
scripts/ensure-libretranslate-dirs.sh

@ -1,19 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-shot host prep for editor stack sidecars: # One-shot host prep for editor stack sidecars:
# 1) LibreTranslate bind mount — dirs + ownership UID 1032 (official image user). # 1) LibreTranslate bind mount — dirs + ownership UID 1032 (official image user).
# 2) Piper ONNX voices — trinity + read-aloud extras (rhasspy/piper-voices) into: # 2) Recommended Argos / LT_LOAD_ONLY list — written beside the volume (see below).
# - ./.local-piper-data (dev compose bind mount), and # 3) Piper ONNX voices — same set as src/lib/trinity-languages.ts + piper-tts-proxy voiceMap
# - Docker volume <project>_piper-stack-data when it exists (docker-compose.prod.yml Wyoming /data). # into ./.local-piper-data and optionally Docker volume <project>_piper-stack-data.
#
# Argos translation models are pulled by the LibreTranslate **container** from LT_LOAD_ONLY on first
# start (bind mount .local-libretranslate). Default list: scripts/libretranslate-lt.default.env (same file
# as docker-compose `env_file` for libretranslate).
# #
# Piper download logic lives in this file so you can copy **only** this script to a server and run it # Piper download logic lives in this file so you can copy **only** this script to a server and run it
# from the repo root (still need curl, docker; full clone is easier: bash scripts/ensure-libretranslate-dirs.sh). # from the repo root (still need curl, docker; full clone is easier: bash scripts/ensure-libretranslate-dirs.sh).
# #
# Internal entry: bash ensure-libretranslate-dirs.sh --download-piper-only [DEST] # Internal entry: bash ensure-libretranslate-dirs.sh --download-piper-only [DEST]
# (used by scripts/download-piper-extra-voices.sh — keep voice list in sync with trinity-languages.ts) # (used by scripts/download-piper-extra-voices.sh — keep Piper relpaths in sync with trinity-languages.ts)
# #
# Optional env: # Optional env:
# COMPOSE_PROJECT_NAME — Docker Compose project name (default: basename of repo dir), for volume *_piper-stack-data. # COMPOSE_PROJECT_NAME — Docker Compose project name (default: basename of repo dir), for volume *_piper-stack-data.
# SKIP_PIPER_VOICES=1 — only fix LibreTranslate permissions, do not download Piper. # SKIP_PIPER_VOICES=1 — only fix LibreTranslate permissions (+ LT_LOAD_ONLY hint file), do not download Piper.
# HF_BASE — Hugging Face resolve base for Piper ONNX (default rhasspy/piper-voices/main). # HF_BASE — Hugging Face resolve base for Piper ONNX (default rhasspy/piper-voices/main).
set -euo pipefail set -euo pipefail
@ -26,7 +30,21 @@ _resolve_root() {
fi fi
} }
# Keep in sync with src/lib/trinity-languages.ts (TRINITY_PIPER_VOICE + EXTRA_READ_ALOUD_PIPER_VOICE) and server voiceMap. load_stack_lt_load_only() {
local f="${ROOT}/scripts/libretranslate-lt.default.env"
[[ -f "$f" ]] || {
echo "[ensure] Missing ${f}" >&2
exit 1
}
STACK_LT_LOAD_ONLY="$(grep -E '^[[:space:]]*LT_LOAD_ONLY=' "$f" | head -1 | sed 's/^[[:space:]]*LT_LOAD_ONLY=//')"
[[ -n "$STACK_LT_LOAD_ONLY" ]] || {
echo "[ensure] LT_LOAD_ONLY empty in ${f}" >&2
exit 1
}
}
# Keep in sync with src/lib/trinity-languages.ts (TRINITY_PIPER_VOICE + EXTRA_READ_ALOUD_PIPER_VOICE)
# and services/piper-tts-proxy/server.ts getVoiceForLanguage voiceMap.
download_piper_voices_to() { download_piper_voices_to() {
local dest="${1:?destination directory}" local dest="${1:?destination directory}"
local hf="${HF_BASE:-https://huggingface.co/rhasspy/piper-voices/resolve/main}" local hf="${HF_BASE:-https://huggingface.co/rhasspy/piper-voices/resolve/main}"
@ -34,6 +52,7 @@ download_piper_voices_to() {
local relpath base_name onnx json local relpath base_name onnx json
for relpath in \ for relpath in \
"en/en_US/lessac/medium/en_US-lessac-medium" \ "en/en_US/lessac/medium/en_US-lessac-medium" \
"en/en_GB/alan/medium/en_GB-alan-medium" \
"de/de_DE/thorsten/medium/de_DE-thorsten-medium" \ "de/de_DE/thorsten/medium/de_DE-thorsten-medium" \
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium" \ "fr/fr_FR/siwis/medium/fr_FR-siwis-medium" \
"es/es_ES/davefx/medium/es_ES-davefx-medium" \ "es/es_ES/davefx/medium/es_ES-davefx-medium" \
@ -61,6 +80,18 @@ download_piper_voices_to() {
echo "Piper ONNX done → ${dest}" echo "Piper ONNX done → ${dest}"
} }
write_lt_load_only_hint() {
local lt_dir="${1:?libretranslate data dir}"
local hint="${lt_dir}/.recommended-lt-load-only.txt"
docker run --rm \
-e STACK_LT_LOAD_ONLY="$STACK_LT_LOAD_ONLY" \
-v "$lt_dir:/d" \
alpine:3.20 \
sh -c 'printf "%s\n" "$STACK_LT_LOAD_ONLY" > /d/.recommended-lt-load-only.txt && chown 1032:1032 /d/.recommended-lt-load-only.txt'
echo "[ensure] Recommended LibreTranslate LT_LOAD_ONLY (Argos) → ${hint}"
echo " Keep in sync with scripts/libretranslate-lt.default.env (compose env_file). Recreate libretranslate once (LT_UPDATE_MODELS=true) after changing the list."
}
if [[ "${1:-}" == "--download-piper-only" ]]; then if [[ "${1:-}" == "--download-piper-only" ]]; then
shift shift
_resolve_root _resolve_root
@ -69,17 +100,28 @@ if [[ "${1:-}" == "--download-piper-only" ]]; then
fi fi
_resolve_root _resolve_root
load_stack_lt_load_only
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT")}" PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT")}"
PIPER_VOL="${PROJECT}_piper-stack-data" PIPER_VOL="${PROJECT}_piper-stack-data"
echo "[ensure] LibreTranslate data dir (UID 1032) …" echo "[ensure] LibreTranslate data dir (UID 1032) …"
if [[ -e "$ROOT/.local-libretranslate" ]] && [[ ! -w "$ROOT/.local-libretranslate" ]]; then
echo "[ensure] Resetting bind-mount ownership so the host can create dirs (final step sets UID 1032) …"
docker run --rm \
-v "$ROOT/.local-libretranslate:/d" \
alpine:3.20 chown -R "$(id -u):$(id -g)" /d
fi
mkdir -p "$ROOT/.local-libretranslate/share" "$ROOT/.local-libretranslate/cache" mkdir -p "$ROOT/.local-libretranslate/share" "$ROOT/.local-libretranslate/cache"
write_lt_load_only_hint "$ROOT/.local-libretranslate"
docker run --rm \ docker run --rm \
-v "$ROOT/.local-libretranslate:/d" \ -v "$ROOT/.local-libretranslate:/d" \
alpine:3.20 chown -R 1032:1032 /d alpine:3.20 chown -R 1032:1032 /d
if [[ "${SKIP_PIPER_VOICES:-}" == "1" ]]; then if [[ "${SKIP_PIPER_VOICES:-}" == "1" ]]; then
echo "[ensure] SKIP_PIPER_VOICES=1 — skipping Piper voice download." echo "[ensure] SKIP_PIPER_VOICES=1 — skipping Piper voice download."
echo "[ensure] Stack languages: translate=${STACK_LT_LOAD_ONLY} (LibreTranslate); grammar=LanguageTool; read-aloud=Piper in .local-piper-data (run again without SKIP to fetch)."
exit 0 exit 0
fi fi
@ -98,3 +140,4 @@ else
fi fi
echo "[ensure] Done." echo "[ensure] Done."
echo "[ensure] Summary — translate: LT_LOAD_ONLY=${STACK_LT_LOAD_ONLY} in Compose; grammar: LanguageTool; read-aloud: Piper ONNX above (+ Wyoming)."

4
scripts/libretranslate-lt.default.env

@ -0,0 +1,4 @@
# Single source for LibreTranslate Argos codes (LT_LOAD_ONLY).
# Referenced by: docker-compose*.yml (libretranslate env_file), scripts/ensure-libretranslate-dirs.sh,
# scripts/prune-libretranslate-packages.{sh,py}. Override for one-off runs: export LT_LOAD_ONLY=… before compose/prune.
LT_LOAD_ONLY=en,de,es,fr,it,pt,ru,zh,ar,nl,pl,cs,tr

18
scripts/prune-libretranslate-packages.py

@ -4,9 +4,23 @@ import os
import re import re
import shutil import shutil
import sys import sys
from pathlib import Path
DEFAULT = "en,de,es,fr,it,pt,ru,zh,ja,ar"
allowed = set(os.environ.get("LT_LOAD_ONLY", DEFAULT).replace(" ", "").split(",")) def default_lt_load_only() -> str:
env_path = Path(__file__).resolve().parent / "libretranslate-lt.default.env"
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("LT_LOAD_ONLY="):
return line.split("=", 1)[1].strip()
raise RuntimeError(f"LT_LOAD_ONLY not set in {env_path}")
allowed = set(
os.environ.get("LT_LOAD_ONLY", default_lt_load_only()).replace(" ", "").split(",")
)
if not allowed or allowed == {""}: if not allowed or allowed == {""}:
print("LT_LOAD_ONLY empty", file=sys.stderr) print("LT_LOAD_ONLY empty", file=sys.stderr)
sys.exit(1) sys.exit(1)

7
scripts/prune-libretranslate-packages.sh

@ -1,10 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Drop Argos packages / MiniSBD files not in LT_LOAD_ONLY (default matches docker-compose libretranslate). # Drop Argos packages / MiniSBD files not in LT_LOAD_ONLY (default: scripts/libretranslate-lt.default.env).
# Stops LibreTranslate briefly so files are not in use; uses Docker+Alpine to delete UID-1032-owned trees. # Stops LibreTranslate briefly so files are not in use; uses Docker+Alpine to delete UID-1032-owned trees.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ARGOS="$ROOT/.local-libretranslate/share/argos-translate" ARGOS="$ROOT/.local-libretranslate/share/argos-translate"
LT_LOAD_ONLY="${LT_LOAD_ONLY:-en,de,es,fr,it,pt,ru,zh,ja,ar}" LT_DEFAULT_ENV="$ROOT/scripts/libretranslate-lt.default.env"
if [[ -z "${LT_LOAD_ONLY:-}" ]]; then
LT_LOAD_ONLY="$(grep -E '^[[:space:]]*LT_LOAD_ONLY=' "$LT_DEFAULT_ENV" | head -1 | sed 's/^[[:space:]]*LT_LOAD_ONLY=//')"
fi
if [[ ! -d "$ARGOS/packages" ]]; then if [[ ! -d "$ARGOS/packages" ]]; then
echo "Nothing to prune (missing $ARGOS/packages)." >&2 echo "Nothing to prune (missing $ARGOS/packages)." >&2

16
services/piper-tts-proxy/server.ts

@ -986,8 +986,10 @@ function detectLanguage(text: string): string {
const italianChars = (sample.match(/[àèéìòùÀÈÉÌÒÙ]/g) || []).length; const italianChars = (sample.match(/[àèéìòùÀÈÉÌÒÙ]/g) || []).length;
// Russian/Cyrillic // Russian/Cyrillic
const cyrillicChars = (sample.match(/[а-яёА-ЯЁ]/g) || []).length; const cyrillicChars = (sample.match(/[а-яёА-ЯЁ]/g) || []).length;
// Chinese/Japanese/Korean (CJK) // CJK scripts: Hangul / kana → English Piper (no ko/ja models); Han → Chinese when dominant.
const cjkChars = (sample.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length; const hangulChars = (sample.match(/[\uac00-\ud7af]/g) || []).length;
const kanaChars = (sample.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length;
const hanChars = (sample.match(/[\u4e00-\u9fff]/g) || []).length;
// Arabic // Arabic
const arabicChars = (sample.match(/[\u0600-\u06ff]/g) || []).length; const arabicChars = (sample.match(/[\u0600-\u06ff]/g) || []).length;
@ -998,12 +1000,15 @@ function detectLanguage(text: string): string {
const spanishRatio = spanishChars / total; const spanishRatio = spanishChars / total;
const italianRatio = italianChars / total; const italianRatio = italianChars / total;
const cyrillicRatio = cyrillicChars / total; const cyrillicRatio = cyrillicChars / total;
const cjkRatio = cjkChars / total; const hangulRatio = hangulChars / total;
const kanaRatio = kanaChars / total;
const hanRatio = hanChars / total;
const arabicRatio = arabicChars / total; const arabicRatio = arabicChars / total;
// Detect based on highest ratio // Detect based on highest ratio
if (cyrillicRatio > 0.1) return 'ru'; if (cyrillicRatio > 0.1) return 'ru';
if (cjkRatio > 0.1) return 'zh'; // Default to Chinese for CJK if (hangulRatio > 0.06 || kanaRatio > 0.02) return 'en';
if (hanRatio > 0.1) return 'zh';
if (arabicRatio > 0.1) return 'ar'; if (arabicRatio > 0.1) return 'ar';
if (germanRatio > 0.02) return 'de'; if (germanRatio > 0.02) return 'de';
if (frenchRatio > 0.02) return 'fr'; if (frenchRatio > 0.02) return 'fr';
@ -1027,6 +1032,7 @@ function getVoiceForLanguage(lang: string): string {
// Voice map keys / ids: keep in sync with `src/lib/trinity-languages.ts` (`TRINITY_PIPER_VOICE`, `EXTRA_READ_ALOUD_PIPER_VOICE`). // Voice map keys / ids: keep in sync with `src/lib/trinity-languages.ts` (`TRINITY_PIPER_VOICE`, `EXTRA_READ_ALOUD_PIPER_VOICE`).
const voiceMap: Record<string, string> = { const voiceMap: Record<string, string> = {
'en': 'en_US-lessac-medium', // Default English voice 'en': 'en_US-lessac-medium', // Default English voice
'en-gb': 'en_GB-alan-medium', // British English (rhasspy/piper-voices; install via scripts/download-piper-extra-voices.sh)
'de': 'de_DE-thorsten-medium', // German 'de': 'de_DE-thorsten-medium', // German
'fr': 'fr_FR-siwis-medium', // French 'fr': 'fr_FR-siwis-medium', // French
'es': 'es_ES-davefx-medium', // Spanish 'es': 'es_ES-davefx-medium', // Spanish
@ -1039,8 +1045,6 @@ function getVoiceForLanguage(lang: string): string {
'nl': 'nl_NL-mls-medium', // Dutch 'nl': 'nl_NL-mls-medium', // Dutch
'cs': 'cs_CZ-jirka-medium', // Czech 'cs': 'cs_CZ-jirka-medium', // Czech
'tr': 'tr_TR-dfki-medium', // Turkish 'tr': 'tr_TR-dfki-medium', // Turkish
// 'ja': 'ja_JP-nanami-medium', // Japanese - not available
// 'ko': 'ko_KR-kyungha-medium', // Korean - not available
}; };
return voiceMap[lang] || voiceMap['en']; // Fall back to English return voiceMap[lang] || voiceMap['en']; // Fall back to English

25
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -20,8 +20,7 @@ import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter' import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order' import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import { import {
filterTranslateLanguagesWithGrammarCatalog, buildResolvedTranslateMenuLanguageOptions,
TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS,
translateLanguageOptionMatchesQuery translateLanguageOptionMatchesQuery
} from '@/lib/language-display-meta' } from '@/lib/language-display-meta'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines' import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
@ -31,6 +30,7 @@ import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import { import {
fetchTranslateLanguages, fetchTranslateLanguages,
isTranslateConfigured, isTranslateConfigured,
translateApiLanguageCode,
type TranslateLanguageOption type TranslateLanguageOption
} from '@/lib/translate-client' } from '@/lib/translate-client'
import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
@ -537,9 +537,7 @@ export default function AdvancedEventLabDialog({
void fetchTranslateLanguages() void fetchTranslateLanguages()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
const base = list.length > 0 ? list : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS const resolved = buildResolvedTranslateMenuLanguageOptions(list)
const filtered = filterTranslateLanguagesWithGrammarCatalog(base)
const resolved = filtered.length > 0 ? filtered : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS
if (!resolved.length) { if (!resolved.length) {
setTranslateLangs([]) setTranslateLangs([])
setTranslateLoad('empty') setTranslateLoad('empty')
@ -547,17 +545,17 @@ export default function AdvancedEventLabDialog({
} }
setTranslateLangs([...resolved]) setTranslateLangs([...resolved])
setTranslateSource('auto') setTranslateSource('auto')
const codes = resolved.map((l) => l.code) const codes = resolved.map((l: TranslateLanguageOption) => l.code)
const tgt = codes.includes('en') ? 'en' : codes[0]! const tgt = codes.includes('en') ? 'en' : codes[0]!
setTranslateTarget(tgt) setTranslateTarget(tgt)
setTranslateLoad('ready') setTranslateLoad('ready')
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
const resolved = TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS const resolved = buildResolvedTranslateMenuLanguageOptions([])
setTranslateLangs([...resolved]) setTranslateLangs([...resolved])
setTranslateSource('auto') setTranslateSource('auto')
const codes = resolved.map((l) => l.code) const codes = resolved.map((l: TranslateLanguageOption) => l.code)
setTranslateTarget(codes.includes('en') ? 'en' : codes[0]!) setTranslateTarget(codes.includes('en') ? 'en' : codes[0]!)
setTranslateLoad('ready') setTranslateLoad('ready')
}) })
@ -769,7 +767,10 @@ 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 (translateLoad !== 'ready' || translateLangs.length === 0) return
if (translateSource !== 'auto' && translateSource === translateTarget) { if (
translateSource !== 'auto' &&
translateApiLanguageCode(translateSource) === translateApiLanguageCode(translateTarget)
) {
toast.message(t('Advanced lab translation same source target')) toast.message(t('Advanced lab translation same source target'))
return return
} }
@ -851,7 +852,7 @@ export default function AdvancedEventLabDialog({
aria-label={t('Language list filter placeholder')} aria-label={t('Language list filter placeholder')}
/> />
</div> </div>
<div className="max-h-52 overflow-y-auto py-1"> <div className="py-1">
{ltListFiltered.map((code) => ( {ltListFiltered.map((code) => (
<SelectItem <SelectItem
key={code} key={code}
@ -906,7 +907,7 @@ export default function AdvancedEventLabDialog({
aria-label={t('Language list filter placeholder')} aria-label={t('Language list filter placeholder')}
/> />
</div> </div>
<div className="max-h-52 overflow-y-auto py-1"> <div className="py-1">
{showTranslateSourceAuto ? ( {showTranslateSourceAuto ? (
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem> <SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
) : null} ) : null}
@ -955,7 +956,7 @@ export default function AdvancedEventLabDialog({
aria-label={t('Language list filter placeholder')} aria-label={t('Language list filter placeholder')}
/> />
</div> </div>
<div className="max-h-52 overflow-y-auto py-1"> <div className="py-1">
{translateLangsFilteredTgt.map((l) => ( {translateLangsFilteredTgt.map((l) => (
<SelectItem <SelectItem
key={l.code} key={l.code}

5
src/components/NoteOptions/DesktopMenu.tsx

@ -53,10 +53,7 @@ const SubMenuPanel = memo(
<Icon /> <Icon />
{action.label} {action.label}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent <DropdownMenuSubContent className="w-[min(28rem,calc(100vw-2rem))] max-w-[28rem] min-w-[18rem] p-0">
className="w-[min(28rem,calc(100vw-2rem))] max-w-[28rem] min-w-[18rem] p-0"
showScrollButtons
>
{action.subMenuSearchable ? ( {action.subMenuSearchable ? (
<div <div
className="border-b border-border bg-popover p-2" className="border-b border-border bg-popover p-2"

11
src/components/NoteOptions/useMenuActions.tsx

@ -68,9 +68,8 @@ import {
type TranslateLanguageOption type TranslateLanguageOption
} from '@/lib/translate-client' } from '@/lib/translate-client'
import { import {
filterTranslateLanguagesWithGrammarCatalog, buildResolvedTranslateMenuLanguageOptions,
languageSelectSingleLine, languageSelectSingleLine,
TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS,
TRANSLATE_LANGUAGE_MENU_ITEM_CLASS TRANSLATE_LANGUAGE_MENU_ITEM_CLASS
} from '@/lib/language-display-meta' } from '@/lib/language-display-meta'
@ -191,14 +190,10 @@ export function useMenuActions({
void fetchTranslateLanguages() void fetchTranslateLanguages()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
const base = list.length > 0 ? list : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions(list))
const filtered = filterTranslateLanguagesWithGrammarCatalog(base)
setTranslateMenuOptions(
filtered.length > 0 ? filtered : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS
)
}) })
.catch(() => { .catch(() => {
if (!cancelled) setTranslateMenuOptions(TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS) if (!cancelled) setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions([]))
}) })
return () => { return () => {
cancelled = true cancelled = true

6
src/components/ui/dropdown-menu.tsx

@ -55,7 +55,7 @@ const DropdownMenuSubContent = React.forwardRef<
DropdownMenuSubContentPositionProps & { DropdownMenuSubContentPositionProps & {
showScrollButtons?: boolean showScrollButtons?: boolean
} }
>(({ className, showScrollButtons = true, side: sideProp, align: alignProp, ...props }, ref) => { >(({ className, showScrollButtons = false, side: sideProp, align: alignProp, ...props }, ref) => {
const [canScrollUp, setCanScrollUp] = React.useState(false) const [canScrollUp, setCanScrollUp] = React.useState(false)
const [canScrollDown, setCanScrollDown] = React.useState(false) const [canScrollDown, setCanScrollDown] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null) const contentRef = React.useRef<HTMLDivElement>(null)
@ -143,7 +143,7 @@ const DropdownMenuSubContent = React.forwardRef<
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
className={cn('p-1 overflow-y-auto', className)} className={cn('p-1 popover-scroll-y', className)}
onScroll={checkScrollability} onScroll={checkScrollability}
> >
{props.children} {props.children}
@ -245,7 +245,7 @@ const DropdownMenuContent = React.forwardRef<
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
className={cn('p-1 overflow-y-auto', className)} className={cn('p-1 popover-scroll-y', className)}
onScroll={checkScrollability} onScroll={checkScrollability}
> >
{props.children} {props.children}

4
src/components/ui/select.tsx

@ -78,17 +78,15 @@ const SelectContent = React.forwardRef<
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
'p-1', 'p-1 popover-scroll-y',
position === 'popper' && position === 'popper' &&
'max-h-[min(24rem,var(--radix-select-content-available-height,80vh))] w-full min-w-[var(--radix-select-trigger-width)]' 'max-h-[min(24rem,var(--radix-select-content-available-height,80vh))] w-full min-w-[var(--radix-select-trigger-width)]'
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) )

46
src/index.css

@ -70,6 +70,52 @@
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
/* Popover / select lists: keep a visible vertical scrollbar (not overlay-only). */
.popover-scroll-y {
overflow-y: scroll;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.popover-scroll-y::-webkit-scrollbar {
width: 10px;
}
.popover-scroll-y::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: hsl(var(--muted-foreground) / 0.35);
}
.popover-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
.popover-scroll-y::-webkit-scrollbar-track {
border-radius: 9999px;
background-color: hsl(var(--muted) / 0.45);
}
/*
* Radix Select injects a sibling <style> that hides scrollbars on the viewport.
* That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select).
*/
[data-radix-select-viewport] {
scrollbar-gutter: stable !important;
scrollbar-width: thin !important;
-ms-overflow-style: auto !important;
}
[data-radix-select-viewport]::-webkit-scrollbar {
display: block !important;
width: 10px !important;
}
[data-radix-select-viewport]::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: hsl(var(--muted-foreground) / 0.35);
}
[data-radix-select-viewport]::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
[data-radix-select-viewport]::-webkit-scrollbar-track {
border-radius: 9999px;
background-color: hsl(var(--muted) / 0.45);
}
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.clickable:hover { .clickable:hover {
background-color: hsl(var(--muted) / 0.3); background-color: hsl(var(--muted) / 0.3);

44
src/lib/language-display-meta.test.ts

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
buildResolvedTranslateMenuLanguageOptions,
expandTranslateOptionsWithEnglishDialects,
filterTranslateLanguagesWithGrammarCatalog, filterTranslateLanguagesWithGrammarCatalog,
getLanguageDisplayParts, getLanguageDisplayParts,
languageSelectSingleLine, languageSelectSingleLine,
@ -32,6 +34,12 @@ describe('ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES', () => {
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).toContain('tr') expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).toContain('tr')
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES.length).toBeGreaterThan(40) expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES.length).toBeGreaterThan(40)
}) })
it('omits Japanese, Korean, and Swahili from default translate-target ordering', () => {
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).not.toContain('ja')
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).not.toContain('ko')
expect(ORDERED_TRANSLATE_GRAMMAR_LANGUAGE_CODES).not.toContain('sw')
})
}) })
describe('filterTranslateLanguagesWithGrammarCatalog', () => { describe('filterTranslateLanguagesWithGrammarCatalog', () => {
@ -43,6 +51,42 @@ describe('filterTranslateLanguagesWithGrammarCatalog', () => {
]) ])
expect(out.map((l) => l.code)).toEqual(['de', 'tr']) expect(out.map((l) => l.code)).toEqual(['de', 'tr'])
}) })
it('drops Swahili even when the translate API advertises it', () => {
const out = filterTranslateLanguagesWithGrammarCatalog([{ code: 'sw', name: 'Swahili' }])
expect(out.map((l) => l.code)).toEqual([])
})
})
describe('buildResolvedTranslateMenuLanguageOptions', () => {
it('adds en-gb when API has en (no Swahili injection)', () => {
const out = buildResolvedTranslateMenuLanguageOptions([
{ code: 'en', name: 'English' },
{ code: 'de', name: 'German' }
])
expect(out.map((l) => l.code)).not.toContain('sw')
expect(out.map((l) => l.code)).toContain('en-gb')
})
})
describe('expandTranslateOptionsWithEnglishDialects', () => {
it('inserts en-gb after plain en and relabels en as US English', () => {
const out = expandTranslateOptionsWithEnglishDialects([
{ code: 'de', name: 'German' },
{ code: 'en', name: 'English' }
])
expect(out.map((l) => l.code)).toEqual(['de', 'en', 'en-gb'])
expect(out[1]!.name).toContain('United States')
expect(out[2]!.name).toContain('United Kingdom')
})
it('is a no-op when en-gb is already present', () => {
const base = [
{ code: 'en', name: 'English' },
{ code: 'en-gb', name: 'British' }
]
expect(expandTranslateOptionsWithEnglishDialects(base)).toEqual(base)
})
}) })
describe('translateLanguageOptionMatchesQuery', () => { describe('translateLanguageOptionMatchesQuery', () => {

73
src/lib/language-display-meta.ts

@ -2,9 +2,12 @@
* Canonical ISO-639-style language labels (English + endonym) for selection UI. * Canonical ISO-639-style language labels (English + endonym) for selection UI.
* {@link getLanguageDisplayParts} falls back to `Intl.DisplayNames` when a code is missing here. * {@link getLanguageDisplayParts} falls back to `Intl.DisplayNames` when a code is missing here.
* *
* {@link TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS} lists every map key that LanguageTool also pairs with * {@link TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS} lists map keys that LanguageTool pairs with, minus a small
* (deduped per grammar language). Translate menus use {@link filterTranslateLanguagesWithGrammarCatalog} * exclude list for locales not in the default editor Argos stack. Translate menus use
* on Libre `/languages` so only installed targets are offered. * {@link filterTranslateLanguagesWithGrammarCatalog} on Libre `/languages`, then
* {@link expandTranslateOptionsWithEnglishDialects} adds British English (`en-gb`) when plain `en`
* is installed (Libre still uses API code `en`). {@link buildResolvedTranslateMenuLanguageOptions}
* runs that pipeline for **note menus and Advanced Event Lab** so both stay identical.
* *
* JSX: {@link LanguageSelectOptionLines} in `language-select-option-lines.tsx` (this file stays `.ts` * JSX: {@link LanguageSelectOptionLines} in `language-select-option-lines.tsx` (this file stays `.ts`
* so extensionless imports resolve cleanly under Vite). * so extensionless imports resolve cleanly under Vite).
@ -16,6 +19,15 @@ import {
translateTargetToLanguageToolCode translateTargetToLanguageToolCode
} from '@/lib/languagetool-language-order' } from '@/lib/languagetool-language-order'
/** Bases omitted from translate-target menus (keep aligned with `scripts/libretranslate-lt.default.env`). */
const TRANSLATE_MENU_EXCLUDED_BASE_CODES = new Set(['ja', 'ko', 'sw'])
function isExcludedTranslateMenuLanguageCode(code: string): boolean {
const n = normalizeTranslateLangCode(code).toLowerCase().replace(/_/gu, '-')
const base = (n.split(/-/u)[0] ?? n).toLowerCase()
return TRANSLATE_MENU_EXCLUDED_BASE_CODES.has(base)
}
/** Lowercase keys: ISO 639-1 base, or BCP47 tag for regional overrides. */ /** Lowercase keys: ISO 639-1 base, or BCP47 tag for regional overrides. */
export const LANGUAGE_TRIPLE_BY_LOWER_KEY: Record<string, { english: string; native: string }> = export const LANGUAGE_TRIPLE_BY_LOWER_KEY: Record<string, { english: string; native: string }> =
Object.fromEntries( Object.fromEntries(
@ -299,8 +311,9 @@ export function languageSelectSingleLine(tag: string): string {
* deduped by LT target (shortest tag wins), sorted by English name. * deduped by LT target (shortest tag wins), sorted by English name.
*/ */
export function getOrderedTranslateGrammarLanguageCodes(): readonly string[] { export function getOrderedTranslateGrammarLanguageCodes(): readonly string[] {
const candidates = Object.keys(LANGUAGE_TRIPLE_BY_LOWER_KEY).filter((k) => const candidates = Object.keys(LANGUAGE_TRIPLE_BY_LOWER_KEY).filter(
translateCodeHasLanguageToolPairing(k) (k) =>
translateCodeHasLanguageToolPairing(k) && !isExcludedTranslateMenuLanguageCode(k)
) )
const byLt = new Map<string, string>() const byLt = new Map<string, string>()
for (const c of candidates) { for (const c of candidates) {
@ -351,7 +364,10 @@ export function translateLanguageOptionMatchesQuery(code: string, query: string)
export function filterTranslateLanguagesWithGrammarCatalog( export function filterTranslateLanguagesWithGrammarCatalog(
apiList: readonly TranslateLanguageOption[] apiList: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] { ): TranslateLanguageOption[] {
const withPairing = apiList.filter((l) => translateCodeHasLanguageToolPairing(l.code)) const withPairing = apiList.filter(
(l) =>
translateCodeHasLanguageToolPairing(l.code) && !isExcludedTranslateMenuLanguageCode(l.code)
)
const byLt = new Map<string, TranslateLanguageOption>() const byLt = new Map<string, TranslateLanguageOption>()
for (const l of withPairing) { for (const l of withPairing) {
const lt = translateTargetToLanguageToolCode(l.code) const lt = translateTargetToLanguageToolCode(l.code)
@ -379,6 +395,51 @@ export function filterTranslateLanguagesWithGrammarCatalog(
}) })
} }
/**
* When LibreTranslate advertises `en` only, still offer US vs British as separate targets: both
* call the API with `en` ({@link translateApiLanguageCode}); grammar and Piper use `en-US` vs `en-GB`.
*/
export function expandTranslateOptionsWithEnglishDialects(
opts: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] {
const normalized = opts.map((o) => normalizeTranslateLangCode(o.code).toLowerCase().replace(/_/gu, '-'))
if (!normalized.includes('en') || normalized.includes('en-gb')) {
return [...opts]
}
const enIdx = opts.findIndex(
(o) => normalizeTranslateLangCode(o.code).toLowerCase().replace(/_/gu, '-') === 'en'
)
if (enIdx === -1) return [...opts]
const relabeled = opts.map((o, i) =>
i === enIdx ? { ...o, name: languageSelectSingleLine('en-us') } : o
)
const withGb: TranslateLanguageOption[] = [
...relabeled.slice(0, enIdx + 1),
{ code: 'en-gb', name: languageSelectSingleLine('en-gb') },
...relabeled.slice(enIdx + 1)
]
return withGb
}
/**
* Resolved translate/grammar target list: Libre `/languages` (or static fallback when empty),
* filter LanguageTool (with excluded bases removed), then UK/US English expansion.
* Used by note translate submenus and the Advanced Event Lab dialog so menus never diverge.
*/
export function buildResolvedTranslateMenuLanguageOptions(
apiFetchResult: readonly TranslateLanguageOption[]
): TranslateLanguageOption[] {
const base: readonly TranslateLanguageOption[] =
apiFetchResult.length > 0 ? apiFetchResult : TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS
const pipeline = (opts: readonly TranslateLanguageOption[]) =>
expandTranslateOptionsWithEnglishDialects(filterTranslateLanguagesWithGrammarCatalog([...opts]))
let out = pipeline(base)
if (out.length === 0) {
out = pipeline(TRANSLATE_GRAMMAR_LANGUAGE_OPTIONS)
}
return out
}
/** Submenu / dropdown row: align label block; content is horizontal (see `LanguageSelectOptionLines`). */ /** Submenu / dropdown row: align label block; content is horizontal (see `LanguageSelectOptionLines`). */
export const TRANSLATE_LANGUAGE_MENU_ITEM_CLASS = export const TRANSLATE_LANGUAGE_MENU_ITEM_CLASS =
'!items-start h-auto min-h-0 whitespace-normal py-2 w-full text-left' '!items-start h-auto min-h-0 whitespace-normal py-2 w-full text-left'

13
src/lib/languagetool-language-order.test.ts

@ -11,6 +11,8 @@ describe('translateTargetToLanguageToolCode', () => {
expect(translateTargetToLanguageToolCode('ja')).toBe('ja-JP') expect(translateTargetToLanguageToolCode('ja')).toBe('ja-JP')
expect(translateTargetToLanguageToolCode('de')).toBe('de-DE') expect(translateTargetToLanguageToolCode('de')).toBe('de-DE')
expect(translateTargetToLanguageToolCode('zh-Hans')).toBe('zh-CN') expect(translateTargetToLanguageToolCode('zh-Hans')).toBe('zh-CN')
expect(translateTargetToLanguageToolCode('en')).toBe('en-US')
expect(translateTargetToLanguageToolCode('en-gb')).toBe('en-GB')
}) })
}) })
@ -18,6 +20,7 @@ describe('translateCodeHasLanguageToolPairing', () => {
it('is true for mapped translate codes', () => { it('is true for mapped translate codes', () => {
expect(translateCodeHasLanguageToolPairing('tr')).toBe(true) expect(translateCodeHasLanguageToolPairing('tr')).toBe(true)
expect(translateCodeHasLanguageToolPairing('ja')).toBe(true) expect(translateCodeHasLanguageToolPairing('ja')).toBe(true)
expect(translateCodeHasLanguageToolPairing('en-gb')).toBe(true)
}) })
it('is false for unknown codes', () => { it('is false for unknown codes', () => {
expect(translateCodeHasLanguageToolPairing('zz')).toBe(false) expect(translateCodeHasLanguageToolPairing('zz')).toBe(false)
@ -53,4 +56,14 @@ describe('buildLabLanguageToolPreferenceList', () => {
expect(list[1]).toBe('en-US') expect(list[1]).toBe('en-US')
expect(list).toEqual(['de-DE', 'en-US', 'fr-FR']) expect(list).toEqual(['de-DE', 'en-US', 'fr-FR'])
}) })
it('includes en-GB in the lab LT list when British English is a translate target', () => {
const list = buildLabLanguageToolPreferenceList('en', [
{ code: 'en', name: 'English (US)' },
{ code: 'en-gb', name: 'English (UK)' },
{ code: 'de', name: 'German' }
])
expect(list).toContain('en-US')
expect(list).toContain('en-GB')
})
}) })

2
src/lib/languagetool-language-order.ts

@ -6,6 +6,8 @@ import { normalizeTranslateLangCode } from '@/lib/translate-client'
*/ */
const LT_ALIASES: Record<string, string> = { const LT_ALIASES: Record<string, string> = {
en: 'en-US', en: 'en-US',
'en-us': 'en-US',
'en-gb': 'en-GB',
de: 'de-DE', de: 'de-DE',
fr: 'fr-FR', fr: 'fr-FR',
es: 'es', es: 'es',

13
src/lib/read-aloud.ts

@ -16,6 +16,7 @@ import indexedDb from '@/services/indexed-db.service'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
/** Keep each Piper request small: long JSON bodies and WAV responses can OOM or time out the server. */ /** Keep each Piper request small: long JSON bodies and WAV responses can OOM or time out the server. */
@ -599,7 +600,7 @@ async function speakViaPiperTtsChunks(chunks: string[], piperVoice: string): Pro
async function speakViaWebSpeech( async function speakViaWebSpeech(
text: string, text: string,
title: string, title: string,
options?: { fromPiperFallback?: boolean; browserOnlyNoPiper?: boolean } options?: { fromPiperFallback?: boolean; browserOnlyNoPiper?: boolean; utteranceLang?: string }
): Promise<ReadAloudResult> { ): Promise<ReadAloudResult> {
stopReadAloudPlayback() stopReadAloudPlayback()
readAloudUserPaused = false readAloudUserPaused = false
@ -673,6 +674,10 @@ async function speakViaWebSpeech(
} }
const u = new SpeechSynthesisUtterance(text) const u = new SpeechSynthesisUtterance(text)
const ul = options?.utteranceLang?.trim()
if (ul) {
u.lang = ul.replace(/_/gu, '-')
}
u.onstart = (): void => { u.onstart = (): void => {
patchSnapshot({ patchSnapshot({
phase: 'playing', phase: 'playing',
@ -741,6 +746,8 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
? piperReadAloudProfileLabel(piperProfileCode) ? piperReadAloudProfileLabel(piperProfileCode)
: '' : ''
const utteranceLang = normalizeTranslateLangCode(chosenReadAloudLang).trim()
if (READ_ALOUD_TTS_URL) { if (READ_ALOUD_TTS_URL) {
stopReadAloudPlayback() stopReadAloudPlayback()
readAloudUserPaused = false readAloudUserPaused = false
@ -798,8 +805,8 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
chunkPlaybackRatio: 0 chunkPlaybackRatio: 0
}) })
return await speakViaWebSpeech(text, title, { fromPiperFallback: true }) return await speakViaWebSpeech(text, title, { fromPiperFallback: true, utteranceLang })
} }
return await speakViaWebSpeech(text, title, { browserOnlyNoPiper: true }) return await speakViaWebSpeech(text, title, { browserOnlyNoPiper: true, utteranceLang })
} }

58
src/lib/translate-client.ts

@ -46,8 +46,42 @@ export function normalizeTranslateLangCode(code: string): string {
return (LANG_ALIASES[t] ?? code.trim()) || 'en' return (LANG_ALIASES[t] ?? code.trim()) || 'en'
} }
/**
* LibreTranslate/Argos only registers `en` regional English codes are for grammar (LanguageTool)
* and read-aloud; the translate API still expects `en`.
*/
export function translateApiLanguageCode(code: string): string {
const n = normalizeTranslateLangCode(code).toLowerCase().replace(/_/gu, '-')
if (n === 'en-gb' || n === 'en-us') return 'en'
return normalizeTranslateLangCode(code)
}
export type TranslateLanguageOption = { code: string; name: string } export type TranslateLanguageOption = { code: string; name: string }
function advertisedApiCodeKey(code: string): string {
return translateApiLanguageCode(code).trim().toLowerCase().replace(/_/gu, '-')
}
/** Codes last returned by GET `/languages` (API form, e.g. `en` for `en-gb`). Empty fetch clears this. */
let advertisedTranslateApiCodes: Set<string> | null = null
function recordAdvertisedTranslateCodesFromServer(list: readonly TranslateLanguageOption[]): void {
if (list.length === 0) {
advertisedTranslateApiCodes = null
return
}
advertisedTranslateApiCodes = new Set(list.map((o) => advertisedApiCodeKey(o.code)))
}
/**
* True if we have not yet seen a successful `/languages` response, or the server advertises the
* Libre `target` we would send for this logical menu code.
*/
export function translateServerSupportsLogicalTarget(targetCode: string): boolean {
if (!advertisedTranslateApiCodes) return true
return advertisedTranslateApiCodes.has(advertisedApiCodeKey(targetCode))
}
let languagesCache: { list: TranslateLanguageOption[]; at: number } | null = null let languagesCache: { list: TranslateLanguageOption[]; at: number } | null = null
const LANGUAGES_CACHE_TTL_MS = 60_000 const LANGUAGES_CACHE_TTL_MS = 60_000
@ -80,6 +114,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
if (!base) return [] if (!base) return []
const now = Date.now() const now = Date.now()
if (languagesCache && now - languagesCache.at < LANGUAGES_CACHE_TTL_MS) { if (languagesCache && now - languagesCache.at < LANGUAGES_CACHE_TTL_MS) {
recordAdvertisedTranslateCodesFromServer(languagesCache.list)
return languagesCache.list return languagesCache.list
} }
const url = `${base}/languages` const url = `${base}/languages`
@ -87,22 +122,26 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
if (!res.ok) { if (!res.ok) {
logger.warn('[Translate] /languages failed', { status: res.status }) logger.warn('[Translate] /languages failed', { status: res.status })
languagesCache = null languagesCache = null
advertisedTranslateApiCodes = null
return [] return []
} }
try { try {
const data = (await res.json()) as unknown const data = (await res.json()) as unknown
const list = parseLanguagesResponse(data) const list = parseLanguagesResponse(data)
languagesCache = { list, at: now } languagesCache = { list, at: now }
recordAdvertisedTranslateCodesFromServer(list)
return list return list
} catch (e) { } catch (e) {
logger.warn('[Translate] /languages parse error', { e }) logger.warn('[Translate] /languages parse error', { e })
languagesCache = null languagesCache = null
advertisedTranslateApiCodes = null
return [] return []
} }
} }
export function clearTranslateLanguagesCache(): void { export function clearTranslateLanguagesCache(): void {
languagesCache = null languagesCache = null
advertisedTranslateApiCodes = null
} }
export async function translatePlainText( export async function translatePlainText(
@ -114,9 +153,24 @@ export async function translatePlainText(
if (!base) { if (!base) {
throw new Error('Translation URL not configured') throw new Error('Translation URL not configured')
} }
const resolvedTarget = normalizeTranslateLangCode(targetLang) const resolvedTarget = translateApiLanguageCode(targetLang)
const resolvedSource = const resolvedSource =
sourceLang === 'auto' ? 'auto' : normalizeTranslateLangCode(sourceLang) sourceLang === 'auto' ? 'auto' : translateApiLanguageCode(sourceLang)
if (!translateServerSupportsLogicalTarget(targetLang)) {
const want = advertisedApiCodeKey(targetLang)
throw new Error(
`This translate server does not offer machine translation for “${want}” (that code is not in GET /languages). ` +
'You can still use grammar check and read-aloud on text that is already in that language.'
)
}
if (resolvedSource !== 'auto' && !translateServerSupportsLogicalTarget(sourceLang)) {
const want = advertisedApiCodeKey(sourceLang)
throw new Error(
`This translate server does not offer “${want}” as a source language (not in /languages). Pick another source or use “Detect automatically”.`
)
}
const key = cacheKey(text, resolvedSource, resolvedTarget) 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) {

20
src/lib/trinity-languages.test.ts

@ -14,14 +14,17 @@ describe('getPiperVoiceForChosenLanguage', () => {
expect(r.piperProfileCode).toBe('de') expect(r.piperProfileCode).toBe('de')
}) })
it('routes Japanese and Korean to Chinese Piper', () => { it('uses English Piper for Japanese and Korean (no native ja/ko voices)', () => {
const ja = getPiperVoiceForChosenLanguage('ja') const ja = getPiperVoiceForChosenLanguage('ja')
expect(ja.voice).toBe(TRINITY_PIPER_VOICE.zh) expect(ja.voice).toBe(TRINITY_PIPER_VOICE.en)
expect(ja.usedRelatedVoiceFallback).toBe(true) expect(ja.usedEnglishVoiceFallback).toBe(true)
expect(ja.piperProfileCode).toBe('zh') expect(ja.usedRelatedVoiceFallback).toBe(false)
expect(ja.piperProfileCode).toBe('en')
const ko = getPiperVoiceForChosenLanguage('ko') const ko = getPiperVoiceForChosenLanguage('ko')
expect(ko.piperProfileCode).toBe('zh') expect(ko.voice).toBe(TRINITY_PIPER_VOICE.en)
expect(ko.usedEnglishVoiceFallback).toBe(true)
expect(ko.piperProfileCode).toBe('en')
}) })
it('routes Ukrainian to Russian Piper', () => { it('routes Ukrainian to Russian Piper', () => {
@ -60,6 +63,13 @@ describe('getPiperVoiceForChosenLanguage', () => {
expect(r.piperProfileCode).toBe('ar') expect(r.piperProfileCode).toBe('ar')
}) })
it('uses British Piper for en-gb before base en would pick US', () => {
const r = getPiperVoiceForChosenLanguage('en-gb')
expect(r.voice).toBe(EXTRA_READ_ALOUD_PIPER_VOICE['en-gb'])
expect(r.piperProfileCode).toBe('en-gb')
expect(r.usedEnglishVoiceFallback).toBe(false)
})
it('uses Arabic Piper for regional Arabic tags', () => { it('uses Arabic Piper for regional Arabic tags', () => {
const r = getPiperVoiceForChosenLanguage('ar-SA') const r = getPiperVoiceForChosenLanguage('ar-SA')
expect(r.voice).toBe(EXTRA_READ_ALOUD_PIPER_VOICE.ar) expect(r.voice).toBe(EXTRA_READ_ALOUD_PIPER_VOICE.ar)

28
src/lib/trinity-languages.ts

@ -1,8 +1,9 @@
/** /**
* Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage` (`TRINITY_PIPER_VOICE` + `EXTRA_READ_ALOUD_PIPER_VOICE`). * Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage` (`TRINITY_PIPER_VOICE` +
* Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then * `EXTRA_READ_ALOUD_PIPER_VOICE` for a few extra locales).
* **related** Piper (e.g. Chinese for Japanese/Korean no `ja`/`ko` in rhasspy/piper-voices yet), * Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then extras
* then **English** when no heuristic fits. * (Arabic, Italian, ), then **related** Piper for regional neighbors, then English (including Japanese
* and Korean there is no dedicated Piper ja/ko voice in rhasspy/piper-voices).
* *
* **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`: * **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`:
* Libre `/languages` LanguageTool pairing (installed translate targets only). * Libre `/languages` LanguageTool pairing (installed translate targets only).
@ -51,7 +52,8 @@ export const TRINITY_PIPER_VOICE: Record<TrinityLanguageCode, string> = {
export const EXTRA_READ_ALOUD_PIPER_VOICE: Record<string, string> = { export const EXTRA_READ_ALOUD_PIPER_VOICE: Record<string, string> = {
ar: 'ar_JO-kareem-medium', ar: 'ar_JO-kareem-medium',
it: 'it_IT-paola-medium', it: 'it_IT-paola-medium',
pt: 'pt_BR-cadu-medium' pt: 'pt_BR-cadu-medium',
'en-gb': 'en_GB-alan-medium'
} }
export type PiperReadAloudProfileCode = TrinityLanguageCode | keyof typeof EXTRA_READ_ALOUD_PIPER_VOICE export type PiperReadAloudProfileCode = TrinityLanguageCode | keyof typeof EXTRA_READ_ALOUD_PIPER_VOICE
@ -76,7 +78,8 @@ export const PIPER_READ_ALOUD_PROFILE_LABELS: Record<PiperReadAloudProfileCode,
...TRINITY_LANGUAGE_DISPLAY_NAMES, ...TRINITY_LANGUAGE_DISPLAY_NAMES,
ar: 'العربية', ar: 'العربية',
it: 'Italiano', it: 'Italiano',
pt: 'Português' pt: 'Português',
'en-gb': 'English (United Kingdom)'
} }
export function piperReadAloudProfileLabel(code: PiperReadAloudProfileCode): string { export function piperReadAloudProfileLabel(code: PiperReadAloudProfileCode): string {
@ -121,8 +124,6 @@ export type PiperVoiceResolution = {
* Keep conservative: same-script / regional neighbors only where it helps more than English. * Keep conservative: same-script / regional neighbors only where it helps more than English.
*/ */
const RELATED_PIPER_FOR_BASE: Record<string, TrinityLanguageCode> = { const RELATED_PIPER_FOR_BASE: Record<string, TrinityLanguageCode> = {
ja: 'zh',
ko: 'zh',
uk: 'ru', uk: 'ru',
be: 'ru', be: 'ru',
bg: 'ru', bg: 'ru',
@ -179,8 +180,17 @@ function baseLangTag(raw: string): string {
/** Piper voice for read-aloud: native trinity → related trinity → English. */ /** Piper voice for read-aloud: native trinity → related trinity → English. */
export function getPiperVoiceForChosenLanguage(rawLang: string): PiperVoiceResolution { export function getPiperVoiceForChosenLanguage(rawLang: string): PiperVoiceResolution {
const base = baseLangTag(rawLang)
const full = normalizeTranslateLangCode(rawLang).toLowerCase().replace(/_/gu, '-') const full = normalizeTranslateLangCode(rawLang).toLowerCase().replace(/_/gu, '-')
if (full === 'en-gb' || full.startsWith('en-gb-')) {
return {
voice: EXTRA_READ_ALOUD_PIPER_VOICE['en-gb']!,
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: false,
piperProfileCode: 'en-gb'
}
}
const base = baseLangTag(rawLang)
if (isTrinityLanguageCode(base)) { if (isTrinityLanguageCode(base)) {
return getPiperVoiceForTrinityLanguage(base) return getPiperVoiceForTrinityLanguage(base)

Loading…
Cancel
Save