Translation on Firefox Translations models; new logo
- Email translation moves from LibreTranslate to our own translator
service (translator/): Mozilla's Firefox Translations models on the
Bergamot engine, fastText language detection (176 languages). Polish,
English, German, French, Spanish and Italian on first start (~350 MB
instead of ~10 GB); Admin panel > Email translation installs and
removes languages. Built and pushed as the `translator` image.
- Every message is translated paragraph by paragraph as plain text;
names travel with their sentence and are kept only where the model
changed them; a sentence start the model copied ("Enclosed") is tried
again in lower case; list markers are not sent
- The Translate button shows only for installed languages
- New logo: favicon, app/maskable/iPhone icons, notification badge,
email logo and calendar copy regenerated; BIMI logo rebuilt in SVG
Tiny PS with its gradients; masks render in browsers
- Launch screen background is the app's dark grey
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -21,3 +21,5 @@ RSPAMD_CONTROLLER_PASSWORD=devpass
|
||||
|
||||
# Spelling/grammar check in the compose window (LanguageTool). Empty = off.
|
||||
LANGUAGETOOL_URL=http://127.0.0.1:8010
|
||||
# Email translation (the translator service, translator/). Empty = off.
|
||||
TRANSLATOR_URL=http://127.0.0.1:5005
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
up: ## start services
|
||||
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool libretranslate
|
||||
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool translator
|
||||
down: ## stop services (data kept)
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
|
||||
|
||||
@@ -15,10 +15,12 @@ import re
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import translation
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
@@ -332,3 +334,61 @@ async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) ->
|
||||
except MailServerError as exc:
|
||||
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# --- email translation languages (translator service) ------------------------------
|
||||
|
||||
|
||||
class InstallProgressOut(BaseModel):
|
||||
done: int
|
||||
total: int
|
||||
|
||||
|
||||
class AdminTranslationOut(BaseModel):
|
||||
enabled: bool # TRANSLATOR_URL is set
|
||||
reachable: bool
|
||||
installed: list[str] = []
|
||||
installing: dict[str, InstallProgressOut] = {}
|
||||
available: list[str] = [] # what Mozilla has models for (empty: offline)
|
||||
errors: dict[str, str] = {} # why a language's last install failed
|
||||
|
||||
|
||||
_LANG = Path(pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
|
||||
|
||||
|
||||
@router.get("/translation", response_model=AdminTranslationOut)
|
||||
async def translation_status(admin: CurrentAdmin) -> AdminTranslationOut:
|
||||
if not translation.enabled():
|
||||
return AdminTranslationOut(enabled=False, reachable=False)
|
||||
try:
|
||||
data = await translation.status()
|
||||
except translation.TranslationError:
|
||||
return AdminTranslationOut(enabled=True, reachable=False)
|
||||
return AdminTranslationOut(enabled=True, reachable=True, **data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/translation/languages/{code}",
|
||||
response_model=AdminTranslationOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def install_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
|
||||
"""Download a language's models (to and from English) in the background."""
|
||||
try:
|
||||
await translation.install(code)
|
||||
except translation.TranslationError as exc:
|
||||
raise_api_error(502, "translator_failed", str(exc))
|
||||
return await translation_status(admin)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/translation/languages/{code}",
|
||||
response_model=AdminTranslationOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def remove_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
|
||||
try:
|
||||
await translation.remove(code)
|
||||
except translation.TranslationError as exc:
|
||||
raise_api_error(502, "translator_failed", str(exc))
|
||||
return await translation_status(admin)
|
||||
|
||||
@@ -25,6 +25,6 @@ class TranslationInfoOut(BaseModel):
|
||||
async def translation_languages(user: CurrentUser) -> TranslationInfoOut:
|
||||
found = await translation.languages()
|
||||
return TranslationInfoOut(
|
||||
available=bool(found),
|
||||
available=len(found) > 1, # English alone translates nothing
|
||||
languages=[LanguageOut(code=x.code, name=x.name) for x in found],
|
||||
)
|
||||
|
||||
@@ -71,9 +71,9 @@ class Settings(BaseSettings):
|
||||
# Spelling and grammar checking in the compose window (LanguageTool server,
|
||||
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
|
||||
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
|
||||
# Email translation: a LibreTranslate server (app/translation.py), e.g.
|
||||
# http://libretranslate:5000. Empty: translation is off.
|
||||
libretranslate_url: str = Field(default="", alias="LIBRETRANSLATE_URL")
|
||||
# Email translation: the translator service (translator/, app/translation.py),
|
||||
# e.g. http://translator:5000. Empty: translation is off.
|
||||
translator_url: str = Field(default="", alias="TRANSLATOR_URL")
|
||||
# New passwords are checked against known data breaches (Have I Been Pwned's
|
||||
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
|
||||
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -1,19 +1,22 @@
|
||||
"""Email translation, on this server: LibreTranslate (Argos models) in its own
|
||||
container, reached only by the API. A message's text never leaves the server.
|
||||
"""Email translation, on this server: the translator service (translator/:
|
||||
Mozilla's Firefox Translations models and fastText language identification)
|
||||
in its own container, reached only by the API. A message's text never leaves
|
||||
the server.
|
||||
|
||||
- A message's language is detected the first time it is shown (from its own
|
||||
text: quoted replies and signatures left out) and kept on the message, so the
|
||||
reading pane can offer "Translate" when it differs from the interface language.
|
||||
- A translation is of the message HTML the user sees (already sanitized; the
|
||||
result is sanitized again), cached per message, target language and whether
|
||||
remote images were shown.
|
||||
remote images were shown. It is made paragraph by paragraph, as plain text
|
||||
(translation_prep.split_pieces), each paragraph from its own language.
|
||||
- The languages on offer are those installed on the service (admin panel).
|
||||
|
||||
Off when ``LIBRETRANSLATE_URL`` is empty.
|
||||
Off when ``TRANSLATOR_URL`` is empty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html as html_lib
|
||||
import logging
|
||||
import re
|
||||
@@ -30,10 +33,10 @@ from app.config import get_settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECT_CHARS = 1500 # of the message's own text: plenty to tell the language
|
||||
MIN_CONFIDENCE = 50.0 # below this, LibreTranslate is guessing
|
||||
MIN_CONFIDENCE = 50.0 # below this, the detector is guessing
|
||||
MAX_HTML_CHARS = 150_000 # a translation request bigger than this is refused
|
||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # long messages take a while on a CPU
|
||||
_LANG_CACHE_S = 600
|
||||
_LANG_CACHE_S = 60
|
||||
|
||||
|
||||
class TranslationError(Exception):
|
||||
@@ -47,65 +50,108 @@ class Language:
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(get_settings().libretranslate_url.strip())
|
||||
return bool(get_settings().translator_url.strip())
|
||||
|
||||
|
||||
def _url(path: str) -> str:
|
||||
return get_settings().libretranslate_url.rstrip("/") + path
|
||||
return get_settings().translator_url.rstrip("/") + path
|
||||
|
||||
|
||||
async def _post(path: str, payload: dict[str, object]) -> object:
|
||||
async def _call(method: str, path: str, payload: dict[str, object] | None = None) -> object:
|
||||
if not enabled():
|
||||
raise TranslationError("translation is not set up on this server")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(_url(path), json=payload)
|
||||
resp = await client.request(method, _url(path), json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
|
||||
if resp.status_code != 200:
|
||||
if resp.status_code not in (200, 202):
|
||||
detail = resp.text[:200]
|
||||
raise TranslationError(f"the translation service refused: {resp.status_code} {detail}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _post(path: str, payload: dict[str, object]) -> object:
|
||||
return await _call("POST", path, payload)
|
||||
|
||||
|
||||
_languages: tuple[float, list[Language]] = (0.0, [])
|
||||
|
||||
|
||||
async def languages() -> list[Language]:
|
||||
"""What the service can translate between (cached for a few minutes)."""
|
||||
"""The installed languages, English among them (cached for a minute). The
|
||||
browser names them in the interface language; ``name`` is the code."""
|
||||
global _languages
|
||||
if not enabled():
|
||||
return []
|
||||
stamp, cached = _languages
|
||||
if cached and time.monotonic() - stamp < _LANG_CACHE_S:
|
||||
return cached
|
||||
if not enabled():
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
resp = await client.get(_url("/languages"))
|
||||
resp.raise_for_status()
|
||||
rows = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
codes = resp.json()["installed"]
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
|
||||
logger.info("translation languages unavailable: %s", exc)
|
||||
return []
|
||||
found = [Language(code=str(r["code"]), name=str(r["name"])) for r in rows if "code" in r]
|
||||
found = [Language(code=str(c), name=str(c)) for c in codes]
|
||||
_languages = (time.monotonic(), found)
|
||||
return found
|
||||
|
||||
|
||||
def forget_languages() -> None:
|
||||
"""After an install or removal: ask the service again."""
|
||||
global _languages
|
||||
_languages = (0.0, [])
|
||||
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
"""The service's own account (admin panel): installed, installing (bytes
|
||||
done of total), available from Mozilla, and why an install failed."""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=3.0)) as client:
|
||||
try:
|
||||
resp = await client.get(_url("/languages"))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def install(code: str) -> None:
|
||||
await _call("POST", f"/languages/{code}")
|
||||
forget_languages()
|
||||
|
||||
|
||||
async def remove(code: str) -> None:
|
||||
await _call("DELETE", f"/languages/{code}")
|
||||
forget_languages()
|
||||
|
||||
|
||||
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]")
|
||||
MIN_LETTERS = 25 # fewer, and the detector guesses ("Jacqueline Lian" → Esperanto)
|
||||
MAX_DETECT = 60 # pieces of one message asked about
|
||||
MIN_SHARE = 0.15 # of a message's letters, for its language to count
|
||||
|
||||
|
||||
async def _detect_many(texts: list[str]) -> list[str]:
|
||||
"""The language of each of ``texts`` ('' where unsure), in one request."""
|
||||
if not texts:
|
||||
return []
|
||||
result = await _post("/detect", {"q": [t[:DETECT_CHARS] for t in texts]})
|
||||
if not isinstance(result, list) or len(result) != len(texts):
|
||||
return [""] * len(texts)
|
||||
return [
|
||||
str(r.get("language", ""))
|
||||
if isinstance(r, dict) and float(r.get("confidence", 0)) >= MIN_CONFIDENCE
|
||||
else ""
|
||||
for r in result
|
||||
]
|
||||
|
||||
|
||||
async def _detect_one(text: str) -> str:
|
||||
result = await _post("/detect", {"q": text[:DETECT_CHARS]})
|
||||
if not isinstance(result, list) or not result:
|
||||
return ""
|
||||
best = result[0]
|
||||
if not isinstance(best, dict) or float(best.get("confidence", 0)) < MIN_CONFIDENCE:
|
||||
return ""
|
||||
return str(best.get("language", ""))
|
||||
return (await _detect_many([text]))[0]
|
||||
|
||||
|
||||
def _weight(text: str) -> int:
|
||||
@@ -126,13 +172,8 @@ async def languages_of(parts: list[str]) -> list[str]:
|
||||
for i, part in enumerate(parts)
|
||||
if _CJK.search(part) or sum(c.isalpha() for c in part) >= MIN_LETTERS
|
||||
][:MAX_DETECT]
|
||||
gate = asyncio.Semaphore(4)
|
||||
|
||||
async def one(i: int) -> None:
|
||||
async with gate:
|
||||
told[i] = await _detect_one(parts[i])
|
||||
|
||||
await asyncio.gather(*(one(i) for i in asked))
|
||||
for i, language in zip(asked, await _detect_many([parts[i] for i in asked]), strict=True):
|
||||
told[i] = language
|
||||
out = list(told)
|
||||
following = ""
|
||||
for i in reversed(range(len(parts))):
|
||||
@@ -178,47 +219,163 @@ def _base(code: str) -> str:
|
||||
return code.lower().split("-")[0]
|
||||
|
||||
|
||||
async def _translate_paragraphs(html: str, target: str) -> str:
|
||||
"""``html`` in more than one language, in ``target``: each paragraph from
|
||||
its own language; paragraphs already in ``target`` are left as they are."""
|
||||
async def _texts(texts: list[str], source: str, target: str) -> list[str]:
|
||||
"""``texts`` translated, in batches."""
|
||||
out: list[str] = []
|
||||
for start in range(0, len(texts), 100):
|
||||
batch = texts[start : start + 100]
|
||||
result = await _post("/translate", {"q": batch, "source": source, "target": target})
|
||||
got = result.get("translatedText") if isinstance(result, dict) else None
|
||||
if not isinstance(got, list) or len(got) != len(batch):
|
||||
raise TranslationError("the translation service sent no translation")
|
||||
out += [str(x) for x in got]
|
||||
return out
|
||||
|
||||
|
||||
def _kept(name: str, translated: str) -> bool:
|
||||
"""Whether ``name`` came through a translation: each of its words still
|
||||
there, give or take an ending ("Lukas" → "Lukasie": yes; "Milan" →
|
||||
"Mediolan": no)."""
|
||||
return all(
|
||||
word[: max(3, len(word) - 2)] in translated for word in name.split() if len(word) >= 2
|
||||
)
|
||||
|
||||
|
||||
# A capitalised word that starts a sentence ("Enclosed is a summary…").
|
||||
_SENTENCE_START = re.compile(r"(?:^|(?<=[.!?…]\s))(?P<word>[^\W\d_][^\W\d_'’-]{2,})\b")
|
||||
|
||||
|
||||
def _copied_starts(core: str, text: str, names: list[str]) -> list[str]:
|
||||
"""Words that start a sentence of ``core`` and came through ``text``
|
||||
untranslated, and are no known name: the models take a capitalised
|
||||
sentence start for a name ("Enclosed is…" → "Enclosed jest…")."""
|
||||
found: list[str] = []
|
||||
for m in _SENTENCE_START.finditer(core):
|
||||
word = m.group("word")
|
||||
if not word[0].isupper() or word.isupper() or any(word in n for n in names):
|
||||
continue
|
||||
if re.search(rf"(?<!\w){re.escape(word)}(?!\w)", text):
|
||||
found.append(word)
|
||||
return found
|
||||
|
||||
|
||||
def _lowered(core: str, words: list[str]) -> str:
|
||||
"""``core`` with ``words`` lower-cased where they start a sentence."""
|
||||
return _SENTENCE_START.sub(
|
||||
lambda m: m.group(0).lower() if m.group("word") in words else m.group(0), core
|
||||
)
|
||||
|
||||
|
||||
def _capitalised(text: str) -> str:
|
||||
"""Sentence starts capitalised again (after ``_lowered``): the first
|
||||
letter, and one after a sentence's end — a full stop after a word of four
|
||||
letters or more, so not after "r.", "np.", "tzw." (abbreviations)."""
|
||||
text = re.sub(r"^(\W*)([^\W\d_])", lambda m: m.group(1) + m.group(2).upper(), text)
|
||||
return re.sub(
|
||||
r"((?:[^\W\d_]{4,}[.…]|[!?])\s+)([^\W\d_])",
|
||||
lambda m: m.group(1) + m.group(2).upper(),
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
def _as_before(text: str, first: str) -> str:
|
||||
"""A word after a comma capitalised in ``text`` but not in ``first`` (the
|
||||
first try) is lower-cased: lower-cased input makes the models capitalise
|
||||
elsewhere ("…2026, od 13 lipca" → "…2026 r., Od 13 lipca")."""
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
word = m.group(2)
|
||||
lower = word[0].lower() + word[1:]
|
||||
return m.group(1) + (lower if f", {lower}" in first else word)
|
||||
|
||||
return re.sub(r"(,\s+)([^\W\d_][\w'’-]*)", repl, text)
|
||||
|
||||
|
||||
async def _with_names_kept(core: str, names: list[str], source: str, target: str) -> str:
|
||||
"""``core`` translated around ``names``, which are left as they are."""
|
||||
pattern = re.compile("(" + "|".join(re.escape(n) for n in names) + ")")
|
||||
parts = pattern.split(core)
|
||||
todo = [
|
||||
(i, m.groups())
|
||||
for i, part in enumerate(parts)
|
||||
if i % 2 == 0 and (m := _EDGE_SPACE.match(part)) and any(c.isalnum() for c in part)
|
||||
]
|
||||
done = await _texts([groups[1] for _, groups in todo], source, target)
|
||||
for (i, (lead, _, trail)), text in zip(todo, done, strict=True):
|
||||
parts[i] = f"{lead}{text}{trail}"
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _translate_paragraphs(
|
||||
html: str, target: str, sources: list[str], names: Iterable[str] = ()
|
||||
) -> str:
|
||||
"""``html`` in ``target``, as plain text paragraph by paragraph: from the
|
||||
one language in ``sources``, or (several, or none known) each paragraph
|
||||
from its own. Paragraphs already in ``target``, or in a language that is
|
||||
not installed, are left as they are.
|
||||
|
||||
``names`` go along with their sentence, which then translates as a whole
|
||||
(kept apart, "I came across <Imaginary Pixels>" lost its verb: "Przyszedłem
|
||||
Imaginary Pixels"). Only a piece where one came out changed ("Milan" →
|
||||
"Mediolan") is translated again with it left as it is."""
|
||||
watch = sorted({n.strip() for n in names if len(n.strip()) >= 2}, key=len, reverse=True)
|
||||
pieces, paragraphs = prep.split_pieces(html)
|
||||
if len(sources) == 1:
|
||||
found = [sources[0]] * len(paragraphs)
|
||||
else:
|
||||
found = await languages_of([p.text for p in paragraphs])
|
||||
installed = {x.code for x in await languages()}
|
||||
# Chinese and Japanese go without spaces: two pieces of one sentence
|
||||
# ("…奎琳,</span>我从…") need one between them once translated.
|
||||
followed = {i for p in paragraphs for i in p.pieces[:-1]}
|
||||
by_language: dict[str, list[int]] = {}
|
||||
for paragraph, language in zip(paragraphs, found, strict=True):
|
||||
if language and _base(language) != _base(target):
|
||||
if language in installed and _base(language) != _base(target):
|
||||
by_language.setdefault(language, []).extend(paragraph.pieces)
|
||||
for language, where in by_language.items():
|
||||
todo: list[tuple[int, str, str, str]] = []
|
||||
for i in where:
|
||||
m = _EDGE_SPACE.match(html_lib.unescape(pieces[i]))
|
||||
lead, core, trail = m.groups() if m else ("", "", "")
|
||||
# A list marker stays out: the models repeat it ("1. Thursday" →
|
||||
# "1. 1. Czwartek").
|
||||
marker = _LIST_MARKER.match(core)
|
||||
if marker:
|
||||
lead, core = lead + marker.group(0), core[marker.end() :]
|
||||
if not trail and i in followed and _base(language) in _NO_SPACES:
|
||||
trail = " "
|
||||
if any(c.isalnum() for c in core):
|
||||
todo.append((i, lead, core, trail))
|
||||
for start in range(0, len(todo), 100):
|
||||
batch = todo[start : start + 100]
|
||||
result = await _post(
|
||||
"/translate",
|
||||
{"q": [core for _, _, core, _ in batch], "source": language, "target": target},
|
||||
done = await _texts([core for _, _, core, _ in todo], language, target)
|
||||
# Sentence starts that came through untranslated: once more, lower-case.
|
||||
again = [
|
||||
(k, words)
|
||||
for k, ((_, _, core, _), text) in enumerate(zip(todo, done, strict=True))
|
||||
if (words := _copied_starts(core, text, watch))
|
||||
]
|
||||
if again:
|
||||
retried = await _texts(
|
||||
[_lowered(todo[k][2], words) for k, words in again], language, target
|
||||
)
|
||||
out = result.get("translatedText") if isinstance(result, dict) else None
|
||||
if not isinstance(out, list) or len(out) != len(batch):
|
||||
raise TranslationError("the translation service sent no translation")
|
||||
for (i, lead, _, trail), text in zip(batch, out, strict=True):
|
||||
for (k, words), text in zip(again, retried, strict=True):
|
||||
# Still there: a name after all, and the first try stands.
|
||||
if len(_copied_starts(todo[k][2], text, watch)) < len(words):
|
||||
done[k] = _as_before(_capitalised(text), done[k])
|
||||
for (i, lead, core, trail), text in zip(todo, done, strict=True):
|
||||
lost = [n for n in watch if n in core and not _kept(n, text)]
|
||||
if lost:
|
||||
text = await _with_names_kept(core, lost, language, target)
|
||||
pieces[i] = html_lib.escape(f"{lead}{text}{trail}", quote=False)
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
_NO_SPACES = {"zh", "ja", "th"}
|
||||
_LIST_MARKER = re.compile(r"(?:\d{1,3}[.)]|[a-zA-Z][.)]|[-•*–])\s+(?=\S)")
|
||||
_EDGE_SPACE = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
|
||||
|
||||
|
||||
# Web and mail addresses in the text: the models break them up ("https: / /
|
||||
# vimeo.com"), so they are marked translate="no", which LibreTranslate keeps.
|
||||
# vimeo.com"), so they are marked translate="no" and not sent.
|
||||
_ADDRESS = re.compile(r"(?:https?://|www\.)[^\s<>\"']+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+", re.IGNORECASE)
|
||||
_TAG = re.compile(r"(<[^>]*>)")
|
||||
|
||||
@@ -243,36 +400,9 @@ def protect_addresses(html: str) -> str:
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_PUNCT = re.compile(r"[\W_]+")
|
||||
MAX_NAMES = 200 # names checked for one message
|
||||
|
||||
|
||||
async def names_to_keep(names: Iterable[str], source: str, target: str) -> set[str]:
|
||||
"""Of ``names``, those the model changes when it translates them alone
|
||||
("Milan" → "Mediolan", "Imaginary Pixels" → "Imaginanal Pixels"); one
|
||||
request for them all. If the check fails, all of them."""
|
||||
wanted = sorted({n.strip() for n in names if len(n.strip()) >= 2})[:MAX_NAMES]
|
||||
if not wanted:
|
||||
return set()
|
||||
try:
|
||||
result = await _post(
|
||||
"/translate", {"q": wanted, "source": source or "auto", "target": target}
|
||||
)
|
||||
except TranslationError:
|
||||
return set(wanted)
|
||||
out = result.get("translatedText") if isinstance(result, dict) else None
|
||||
if not isinstance(out, list) or len(out) != len(wanted):
|
||||
return set(wanted)
|
||||
|
||||
def same(a: str, b: str) -> bool:
|
||||
return _PUNCT.sub("", a).casefold() == _PUNCT.sub("", b).casefold()
|
||||
|
||||
return {name for name, got in zip(wanted, out, strict=True) if not same(name, str(got))}
|
||||
|
||||
|
||||
# Bumped when what goes to the service, or what is done with the result,
|
||||
# changes: translations kept from before are made again.
|
||||
PIPELINE_VERSION = 5
|
||||
PIPELINE_VERSION = 8
|
||||
|
||||
|
||||
async def translate_html(
|
||||
@@ -288,16 +418,6 @@ async def translate_html(
|
||||
prepared = prep.join_wrapped_lines(html)
|
||||
for language in sources:
|
||||
prepared = prep.translate_phrases(prepared, language, target)
|
||||
single = sources[0] if len(sources) == 1 else "auto"
|
||||
candidates = {*names, *prep.find_names(prepared, single if sources else "")}
|
||||
terms = {*keep, *await names_to_keep(candidates, single, target)}
|
||||
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, terms)))
|
||||
if len(sources) > 1:
|
||||
return prep.tidy(await _translate_paragraphs(prepared, target))
|
||||
result = await _post(
|
||||
"/translate",
|
||||
{"q": prepared, "source": source or "auto", "target": target, "format": "html"},
|
||||
)
|
||||
if not isinstance(result, dict) or "translatedText" not in result:
|
||||
raise TranslationError("the translation service sent no translation")
|
||||
return prep.tidy(str(result["translatedText"]))
|
||||
candidates = {*names, *prep.find_names(prepared, sources[0] if sources else "")}
|
||||
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, keep)))
|
||||
return prep.tidy(await _translate_paragraphs(prepared, target, sources, candidates))
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"""Getting a message ready for translation, and tidying what comes back.
|
||||
|
||||
LibreTranslate translates each piece of text between tags on its own, and its
|
||||
small models stumble on things a reader would not: a sentence wrapped onto two
|
||||
lines becomes two sentences ("ask you about" / "Plan for next month"),
|
||||
"Best," becomes "Najlepsze," and a company name gets translated word by word.
|
||||
So before a message goes out:
|
||||
A message is translated as plain text, piece by piece (the text between
|
||||
tags), and the models stumble on things a reader would not: a sentence
|
||||
wrapped onto two lines becomes two sentences ("ask you about" / "Plan for next
|
||||
month"), "Best," becomes "Najlepsze," and a company name gets translated word
|
||||
by word. So before a message goes out:
|
||||
|
||||
- lines that carry on a sentence (no end punctuation, next starts lowercase)
|
||||
are joined, in HTML (<br>, one <div> per line) and in wrapped plain text;
|
||||
- greetings and sign-offs that make a whole line are put in the target
|
||||
language from a small phrase table (English, Polish, German);
|
||||
- names are marked translate="no", which LibreTranslate keeps — the people
|
||||
on the message, the user's own names and signature names, and capitalised
|
||||
multi-word names mid-sentence — but only the names the model would change
|
||||
("Milan" → "Mediolan"): a kept name splits its sentence, and LibreTranslate
|
||||
translates each piece alone ("Dear <kept>Lukas Remis</kept>," →
|
||||
"Kochanie."), so a name the model leaves alone stays in the sentence;
|
||||
- names are marked translate="no" and not sent — the people on the message,
|
||||
the user's own names and signature names, and capitalised multi-word names
|
||||
mid-sentence — but only the names the model would change ("Milan" →
|
||||
"Mediolan"): a kept name splits its sentence into pieces translated alone
|
||||
("Dear <kept>Lukas Remis</kept>," → "Kochanie."), so a name the model
|
||||
leaves alone stays in the sentence;
|
||||
- so are emoji, file names ("A_0023C017_p1CAC.mov") and text without a letter;
|
||||
- a message in more than one language is translated paragraph by paragraph,
|
||||
each from its own language (split_pieces); those in the target stay;
|
||||
- and afterwards, the space LibreTranslate drops after a kept word comes back.
|
||||
- the message is translated paragraph by paragraph (split_pieces), each from
|
||||
its own language when there are several; those in the target stay;
|
||||
- and afterwards, a kept word gets back the spaces around it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -315,7 +315,7 @@ def split_pieces(html: str) -> tuple[list[str], list[Paragraph]]:
|
||||
|
||||
# --- after translation -------------------------------------------------------------
|
||||
|
||||
# LibreTranslate drops the space after a kept span ("Bruton</span>i twoje").
|
||||
# A kept span can lose the space beside it ("Bruton</span>i twoje").
|
||||
# Only kept spans: they still carry translate="no" here (the sanitizer that
|
||||
# runs next takes it off); other spans are the sender's and stay as they are.
|
||||
_KEPT = re.compile(r'(<span translate="no">[^<]*</span>)')
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Email translation's languages in the admin panel (app/api/admin.py), with
|
||||
the translator service replaced by a stand-in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app import translation
|
||||
from tests.api.test_admin_users import World, world # noqa: F401 — the fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]:
|
||||
state: dict[str, list[str]] = {"installed": ["en", "pl"], "calls": []}
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
return {
|
||||
"installed": list(state["installed"]),
|
||||
"installing": {"de": {"done": 10, "total": 40}} if "de" in state["calls"] else {},
|
||||
"available": ["en", "pl", "de", "hu"],
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
async def install(code: str) -> None:
|
||||
state["calls"].append(code)
|
||||
|
||||
async def remove(code: str) -> None:
|
||||
state["installed"].remove(code)
|
||||
|
||||
monkeypatch.setattr(translation, "enabled", lambda: True)
|
||||
monkeypatch.setattr(translation, "status", status)
|
||||
monkeypatch.setattr(translation, "install", install)
|
||||
monkeypatch.setattr(translation, "remove", remove)
|
||||
return state
|
||||
|
||||
|
||||
async def test_admin_installs_and_removes_languages(
|
||||
world: World, # noqa: F811
|
||||
service: dict[str, list[str]],
|
||||
) -> None:
|
||||
async with await world.client(await world.user(admin=True)) as c:
|
||||
got = (await c.get("/api/admin/translation")).json()
|
||||
assert got["reachable"] and got["installed"] == ["en", "pl"]
|
||||
assert got["available"] == ["en", "pl", "de", "hu"]
|
||||
|
||||
got = (await c.post("/api/admin/translation/languages/de")).json()
|
||||
assert service["calls"] == ["de"]
|
||||
assert got["installing"] == {"de": {"done": 10, "total": 40}}
|
||||
|
||||
got = (await c.delete("/api/admin/translation/languages/pl")).json()
|
||||
assert got["installed"] == ["en"]
|
||||
|
||||
assert (await c.post("/api/admin/translation/languages/x;rm")).status_code == 422
|
||||
|
||||
|
||||
async def test_only_admins(world: World, service: dict[str, list[str]]) -> None: # noqa: F811
|
||||
async with await world.client(await world.user()) as c:
|
||||
assert (await c.get("/api/admin/translation")).status_code == 403
|
||||
assert (await c.post("/api/admin/translation/languages/de")).status_code == 403
|
||||
assert service["calls"] == []
|
||||
|
||||
|
||||
async def test_unreachable_service_is_reported(
|
||||
world: World, # noqa: F811
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def down() -> dict[str, object]:
|
||||
raise translation.TranslationError("down")
|
||||
|
||||
monkeypatch.setattr(translation, "enabled", lambda: True)
|
||||
monkeypatch.setattr(translation, "status", down)
|
||||
async with await world.client(await world.user(admin=True)) as c:
|
||||
got = (await c.get("/api/admin/translation")).json()
|
||||
assert got == {
|
||||
"enabled": True,
|
||||
"reachable": False,
|
||||
"installed": [],
|
||||
"installing": {},
|
||||
"available": [],
|
||||
"errors": {},
|
||||
}
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from app import translation
|
||||
from app.translation import detect, languages_of, names_to_keep, protect_addresses, translate_html
|
||||
from app.translation import detect, languages_of, protect_addresses, translate_html
|
||||
|
||||
|
||||
def test_addresses_in_text_are_kept_from_translation() -> None:
|
||||
@@ -25,45 +25,55 @@ def test_style_blocks_are_left_alone() -> None:
|
||||
assert protect_addresses(html) == html
|
||||
|
||||
|
||||
async def test_only_names_the_model_changes_are_kept(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sent: list[object] = []
|
||||
|
||||
async def post(path: str, payload: dict[str, object]) -> object:
|
||||
sent.append(payload["q"])
|
||||
alone = {
|
||||
"Lukas Remis": "Lukas Remis.",
|
||||
"Milan": "Mediolan",
|
||||
"Imaginary Pixels": "Imaginanal",
|
||||
async def test_names_go_with_their_sentence_unless_the_model_changes_them(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sent: list[list[str]] = []
|
||||
model = {
|
||||
"I came across Imaginary Pixels today.": "Natknąłem się dziś na Imaginary Pixels.",
|
||||
"Dear Lukas,": "Drogi Lukasie,", # an ending: still the name
|
||||
"Regards, Milan": "Pozdrawiam, Mediolan", # changed: done again around it
|
||||
"Regards,": "Pozdrawiam,",
|
||||
}
|
||||
return {"translatedText": [alone[q] for q in payload["q"]]} # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setattr(translation, "_post", post)
|
||||
kept = await names_to_keep(
|
||||
["Milan", "Lukas Remis", "Imaginary Pixels", "Milan", "x"], "en", "pl"
|
||||
)
|
||||
assert kept == {"Milan", "Imaginary Pixels"} # "Lukas Remis" stays in its sentence
|
||||
assert len(sent) == 1 # one request for all
|
||||
|
||||
|
||||
async def test_all_names_kept_when_the_check_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def post(path: str, payload: dict[str, object]) -> object:
|
||||
raise translation.TranslationError("down")
|
||||
q = payload["q"]
|
||||
assert isinstance(q, list)
|
||||
sent.append(list(q))
|
||||
return {"translatedText": [model.get(x, x) for x in q]}
|
||||
|
||||
async def installed() -> list[translation.Language]:
|
||||
return [translation.Language(c, c) for c in ("en", "pl")]
|
||||
|
||||
monkeypatch.setattr(translation, "_post", post)
|
||||
assert await names_to_keep(["Milan", "Lukas Remis"], "en", "pl") == {"Milan", "Lukas Remis"}
|
||||
monkeypatch.setattr(translation, "languages", installed)
|
||||
html = "<p>I came across Imaginary Pixels today.</p><p>Dear Lukas,</p><p>Regards, Milan</p>"
|
||||
out = await translate_html(html, "en", "pl", names=["Imaginary Pixels", "Lukas", "Milan"])
|
||||
assert out == (
|
||||
"<p>Natknąłem się dziś na Imaginary Pixels.</p><p>Drogi Lukasie,</p>"
|
||||
"<p>Pozdrawiam, Milan</p>"
|
||||
)
|
||||
assert sent[1:] == [["Regards,"]] # only the piece that lost a name went again
|
||||
|
||||
|
||||
def _fake_detector(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""A detector that knows Chinese signs, Polish letters, else English."""
|
||||
asked: list[str] = []
|
||||
|
||||
async def detect_one(text: str) -> str:
|
||||
def one(text: str) -> str:
|
||||
asked.append(text)
|
||||
if any("\u4e00" <= c <= "\u9fff" for c in text):
|
||||
return "zh-Hans"
|
||||
return "pl" if any(c in "ąćęłńóśźż" for c in text.lower()) else "en"
|
||||
|
||||
monkeypatch.setattr(translation, "_detect_one", detect_one)
|
||||
async def detect_many(texts: list[str]) -> list[str]:
|
||||
return [one(t) for t in texts]
|
||||
|
||||
async def installed() -> list[translation.Language]:
|
||||
return [translation.Language(c, c) for c in ("en", "pl", "zh-Hans")]
|
||||
|
||||
monkeypatch.setattr(translation, "_detect_many", detect_many)
|
||||
monkeypatch.setattr(translation, "languages", installed)
|
||||
return asked
|
||||
|
||||
|
||||
@@ -102,7 +112,7 @@ async def test_each_paragraph_from_its_own_language(monkeypatch: pytest.MonkeyPa
|
||||
q = payload["q"]
|
||||
if path == "/translate" and isinstance(q, list) and payload.get("source") != "auto":
|
||||
calls.append((str(payload["source"]), list(q)))
|
||||
return {"translatedText": [f"PL({x})" for x in q]}
|
||||
return {"translatedText": [f"PL({x.upper()})" for x in q]}
|
||||
return {"translatedText": list(q) if isinstance(q, list) else q} # names: unchanged
|
||||
|
||||
monkeypatch.setattr(translation, "_post", post)
|
||||
@@ -117,7 +127,84 @@ async def test_each_paragraph_from_its_own_language(monkeypatch: pytest.MonkeyPa
|
||||
assert "<div>Widzę, że złożyłeś wniosek o otwarcie sklepu & więcej.</div>" in out
|
||||
# Two pieces of one Chinese sentence get a space between them.
|
||||
assert "PL(我是速卖通的官方客户经理奎琳,) </span>PL(" in out
|
||||
assert "PL(I noticed that you applied to join the platform & more.)" in out.replace(
|
||||
assert "PL(I NOTICED THAT YOU APPLIED TO JOIN THE PLATFORM & MORE.)" in out.replace(
|
||||
"&", "&"
|
||||
)
|
||||
assert '<span translate="no">+48 780 549 877</span>' in out
|
||||
|
||||
|
||||
async def test_one_language_needs_no_detector_and_skips_what_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
asked = _fake_detector(monkeypatch)
|
||||
calls: list[tuple[str, list[str]]] = []
|
||||
|
||||
async def post(path: str, payload: dict[str, object]) -> object:
|
||||
q = payload["q"]
|
||||
if path == "/translate" and isinstance(q, list) and payload.get("source") == "en":
|
||||
calls.append(("en", list(q)))
|
||||
return {"translatedText": [f"PL({x.upper()})" for x in q]}
|
||||
return {"translatedText": list(q) if isinstance(q, list) else q}
|
||||
|
||||
monkeypatch.setattr(translation, "_post", post)
|
||||
html = "<p>Hello there, how are you?</p><p>Short one.</p><p><b>1. Thursday 2nd July</b></p>"
|
||||
out = await translate_html(html, "en", "pl")
|
||||
assert out == (
|
||||
"<p>PL(HELLO THERE, HOW ARE YOU?)</p><p>PL(SHORT ONE.)</p>"
|
||||
"<p><b>1. PL(THURSDAY 2ND JULY)</b></p>" # the list marker is not sent
|
||||
)
|
||||
assert asked == [] # the message's one language is known: no detection
|
||||
# Hungarian is not installed: left as it is, nothing sent.
|
||||
calls.clear()
|
||||
assert await translate_html("<p>Jó napot kívánok</p>", "hu", "pl") == "<p>Jó napot kívánok</p>"
|
||||
assert calls == []
|
||||
|
||||
|
||||
async def test_a_sentence_start_the_model_copied_is_tried_lower_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
model = {
|
||||
"Enclosed is a summary. Natalia wrote it.": (
|
||||
"Enclosed jest podsumowaniem. Natalia to napisała."
|
||||
),
|
||||
"enclosed is a summary. natalia wrote it.": (
|
||||
"załączone jest podsumowanie. Natalia to napisała."
|
||||
),
|
||||
"Natalia wrote it.": "Natalia to napisała.",
|
||||
"natalia wrote it.": "Natalia to napisała.",
|
||||
}
|
||||
sent: list[str] = []
|
||||
|
||||
async def post(path: str, payload: dict[str, object]) -> object:
|
||||
q = payload["q"]
|
||||
assert isinstance(q, list)
|
||||
sent.extend(q)
|
||||
return {"translatedText": [model[x] for x in q]}
|
||||
|
||||
async def installed() -> list[translation.Language]:
|
||||
return [translation.Language(c, c) for c in ("en", "pl")]
|
||||
|
||||
monkeypatch.setattr(translation, "_post", post)
|
||||
monkeypatch.setattr(translation, "languages", installed)
|
||||
out = await translate_html("<p>Enclosed is a summary. Natalia wrote it.</p>", "en", "pl")
|
||||
assert out == "<p>Załączone jest podsumowanie. Natalia to napisała.</p>"
|
||||
# A name comes back the same lower-case too: the first try stands.
|
||||
sent.clear()
|
||||
assert await translate_html("<p>Natalia wrote it.</p>", "en", "pl") == (
|
||||
"<p>Natalia to napisała.</p>"
|
||||
)
|
||||
assert sent == ["Natalia wrote it.", "natalia wrote it."]
|
||||
|
||||
|
||||
def test_capitalised_again_but_not_after_abbreviations() -> None:
|
||||
assert translation._capitalised("załączony na lipiec 2026 r., od 13 lipca. dalej np. tak") == (
|
||||
"Załączony na lipiec 2026 r., od 13 lipca. Dalej np. tak"
|
||||
)
|
||||
|
||||
|
||||
def test_a_comma_word_keeps_the_first_try_case() -> None:
|
||||
first = "Enclosed jest podsumowaniem na lipiec 2026, od 13 lipca."
|
||||
again = "Załączony jest podsumowanie na lipiec 2026 r., Od 13 lipca, Anna wie."
|
||||
assert translation._as_before(again, first) == (
|
||||
"Załączony jest podsumowanie na lipiec 2026 r., od 13 lipca, Anna wie."
|
||||
)
|
||||
|
||||
@@ -45,13 +45,15 @@ build() {
|
||||
|
||||
build backend Dockerfile.backend
|
||||
build web Dockerfile.frontend
|
||||
build translator Dockerfile.translator
|
||||
|
||||
if [ "$MODE" = "--save" ]; then
|
||||
out="${ROOT_DIR}/webmail-${TAG}.tar"
|
||||
echo "== saving both images to ${out}"
|
||||
echo "== saving the images to ${out}"
|
||||
docker save -o "$out" \
|
||||
"${REGISTRY_PREFIX}/backend:${TAG}" \
|
||||
"${REGISTRY_PREFIX}/web:${TAG}"
|
||||
"${REGISTRY_PREFIX}/web:${TAG}" \
|
||||
"${REGISTRY_PREFIX}/translator:${TAG}"
|
||||
echo "done. Copy it to the NAS and run: docker load -i $(basename "$out")"
|
||||
exit 0
|
||||
fi
|
||||
@@ -72,6 +74,8 @@ fi
|
||||
echo "== pushing"
|
||||
docker push "${REGISTRY_PREFIX}/backend:${TAG}"
|
||||
docker push "${REGISTRY_PREFIX}/web:${TAG}"
|
||||
docker push "${REGISTRY_PREFIX}/translator:${TAG}"
|
||||
docker push "${REGISTRY_PREFIX}/backend:latest"
|
||||
docker push "${REGISTRY_PREFIX}/web:latest"
|
||||
docker push "${REGISTRY_PREFIX}/translator:latest"
|
||||
echo "pushed ${TAG}. On the NAS: ./deploy/webmail.sh update ${TAG}"
|
||||
|
||||
@@ -436,6 +436,24 @@ print('OFF' if not url else urllib.request.urlopen(url + '/v2/languages', timeou
|
||||
printf ' %s\n' "$lt" ;;
|
||||
esac
|
||||
|
||||
# Email translation: is the translator answering, with which languages?
|
||||
tr="$(compose exec -T api python -c "
|
||||
import json, os, urllib.request
|
||||
url = os.environ.get('TRANSLATOR_URL', '')
|
||||
if not url:
|
||||
print('OFF')
|
||||
else:
|
||||
d = json.load(urllib.request.urlopen(url + '/languages', timeout=5))
|
||||
busy = ', '.join(d.get('installing') or {})
|
||||
print('OK ' + ' '.join(d['installed']) + (' (installing: ' + busy + ')' if busy else ''))
|
||||
" 2>&1 | tail -1)" || true
|
||||
case "$tr" in
|
||||
OK*) grn " translation answering; languages: ${tr#OK }" ;;
|
||||
OFF) ylw " translation is off (TRANSLATOR_URL is empty)" ;;
|
||||
*) ylw " translation not answering — the Translate button stays hidden"
|
||||
printf ' %s\n' "$tr" ;;
|
||||
esac
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
local p
|
||||
for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do
|
||||
@@ -503,7 +521,7 @@ cmd_pull() {
|
||||
Set REGISTRY_PREFIX and IMAGE_TAG in ${ENV_FILE}, or use: webmail.sh up --build"
|
||||
detect_compose
|
||||
IMAGE_TAG="$tag" compose pull || die "pull failed — is the registry reachable and logged in?"
|
||||
grn "pulled ${REGISTRY_PREFIX}/{backend,web}:${tag}. Next: ./deploy/webmail.sh up"
|
||||
grn "pulled ${REGISTRY_PREFIX}/{backend,web,translator}:${tag}. Next: ./deploy/webmail.sh up"
|
||||
}
|
||||
|
||||
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
|
||||
@@ -587,6 +605,10 @@ cmd_update() {
|
||||
# then the API, then the web front end.
|
||||
IMAGE_TAG="$target" compose up -d worker-sync worker-jobs
|
||||
IMAGE_TAG="$target" compose up -d api web
|
||||
# The translator is ours too, so it follows the tag. Named here (not left
|
||||
# to --no-recreate below) so it is replaced on every update, and so init,
|
||||
# which it depends on, runs again when a release adds a data directory.
|
||||
IMAGE_TAG="$target" compose up -d translator
|
||||
# Converge anything the ordered roll does not name — a release that adds a
|
||||
# service (the antispam engine did) would otherwise never start it, and the
|
||||
# stack would look updated while missing a container. --no-recreate so this
|
||||
|
||||
@@ -72,7 +72,7 @@ x-backend-env: &backend-env
|
||||
# Spelling and grammar check in the compose window. Empty turns it off.
|
||||
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL-http://languagetool:8010}
|
||||
# Email translation; empty turns it off.
|
||||
LIBRETRANSLATE_URL: ${LIBRETRANSLATE_URL-http://libretranslate:5000}
|
||||
TRANSLATOR_URL: ${TRANSLATOR_URL-http://translator:5000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
UNDO_SECONDS: ${UNDO_SECONDS:-10}
|
||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
||||
@@ -111,13 +111,13 @@ services:
|
||||
- |
|
||||
set -e
|
||||
mkdir -p /mnt/postgres /mnt/blobs /mnt/backups /mnt/redis /mnt/meili /mnt/state \
|
||||
/mnt/languagetool-ngrams /mnt/libretranslate
|
||||
/mnt/languagetool-ngrams /mnt/translator
|
||||
# Only recurse when ownership is actually wrong: `chown -R` over a large
|
||||
# blob store on every start would be minutes of pointless disk churn.
|
||||
[ "$$(stat -c %u /mnt/blobs)" = "10001" ] || chown -R 10001:10001 /mnt/blobs
|
||||
[ "$$(stat -c %u /mnt/backups)" = "999" ] || chown -R 999:999 /mnt/backups
|
||||
[ "$$(stat -c %u /mnt/redis)" = "999" ] || chown -R 999:999 /mnt/redis
|
||||
[ "$$(stat -c %u /mnt/libretranslate)" = "1032" ] || chown -R 1032:65534 /mnt/libretranslate
|
||||
[ "$$(stat -c %u /mnt/translator)" = "10001" ] || chown -R 10001:10001 /mnt/translator
|
||||
chmod 700 /mnt/backups
|
||||
echo "[init] data directories ready"
|
||||
volumes:
|
||||
@@ -316,30 +316,26 @@ services:
|
||||
cpus: ${LANGUAGETOOL_CPUS:-1.0}
|
||||
logging: *logging
|
||||
|
||||
# Email translation (app/translation.py), all on this server. The first
|
||||
# start downloads every language model (a few GB) into DATA_ROOT; later
|
||||
# starts reuse them. A model is loaded into memory the first time its
|
||||
# language is used.
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:v1.9.6
|
||||
# Email translation (translator/, app/translation.py), all on this server:
|
||||
# Mozilla's Firefox Translations models and fastText language detection.
|
||||
# The first start downloads the default languages (about 350 MB) into
|
||||
# DATA_ROOT; more are installed from the admin panel.
|
||||
translator:
|
||||
image: ${REGISTRY_PREFIX:-webmail}/translator:${IMAGE_TAG:-local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.translator
|
||||
environment:
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
LT_THREADS: ${LIBRETRANSLATE_THREADS:-2}
|
||||
TRANSLATOR_LANGUAGES: ${TRANSLATOR_LANGUAGES:-pl,de,fr,es,it}
|
||||
TRANSLATOR_THREADS: ${TRANSLATOR_THREADS:-2}
|
||||
TZ: ${TZ:-UTC}
|
||||
volumes:
|
||||
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/libretranslate:/home/libretranslate/.local
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
|
||||
interval: 30s
|
||||
timeout: 6s
|
||||
retries: 5
|
||||
start_period: 30m # the first start downloads the models
|
||||
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/translator:/data
|
||||
depends_on:
|
||||
init: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${LIBRETRANSLATE_MEM_LIMIT:-6g}
|
||||
cpus: ${LIBRETRANSLATE_CPUS:-2.0}
|
||||
mem_limit: ${TRANSLATOR_MEM_LIMIT:-2g}
|
||||
cpus: ${TRANSLATOR_CPUS:-2.0}
|
||||
logging: *logging
|
||||
|
||||
# Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Translation service (translator/translator.py): Mozilla's Firefox
|
||||
# Translations models on the Bergamot engine, and fastText language
|
||||
# identification. Python 3.10: the newest the bergamot package is built for.
|
||||
#
|
||||
# docker build -f docker/Dockerfile.translator -t webmail/translator .
|
||||
#
|
||||
# The models are not in the image: they are downloaded into /data (a bind
|
||||
# mount) on the first start and from the admin panel.
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DATA_DIR=/data \
|
||||
HOME=/tmp
|
||||
|
||||
COPY translator/requirements.txt /app/requirements.txt
|
||||
RUN pip install -r /app/requirements.txt
|
||||
|
||||
# fastText's language identifier (lid.176, compressed; CC BY-SA 3.0).
|
||||
ADD --checksum=sha256:8f3472cfe8738a7b6099e8e999c3cbfae0dcd15696aac7d7738a8039db603e83 \
|
||||
https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz /opt/lid.176.ftz
|
||||
|
||||
COPY translator/translator.py /app/translator.py
|
||||
|
||||
# The same uid as the backend: DATA_ROOT/translator is owned by it (init).
|
||||
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin translator \
|
||||
&& mkdir -p /data && chown translator /data /opt/lid.176.ftz
|
||||
USER translator
|
||||
WORKDIR /app
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=4)"
|
||||
|
||||
CMD ["uvicorn", "translator:app", "--host", "0.0.0.0", "--port", "5000", "--no-access-log"]
|
||||
@@ -158,22 +158,18 @@ services:
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
# Email translation (app/translation.py). The first start downloads every
|
||||
# language model (several GB) into the volume; later starts reuse them.
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:v1.9.6
|
||||
environment:
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
# Email translation (translator/, app/translation.py): Firefox Translations
|
||||
# models and fastText. The first start downloads the default languages
|
||||
# (about 350 MB) into the volume; the admin panel adds more.
|
||||
translator:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.translator
|
||||
image: webmail/translator:dev
|
||||
ports:
|
||||
- "127.0.0.1:5005:5000"
|
||||
volumes:
|
||||
- ltmodels:/home/libretranslate/.local
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
|
||||
interval: 15s
|
||||
timeout: 6s
|
||||
retries: 240
|
||||
- translatordata:/data
|
||||
restart: unless-stopped
|
||||
|
||||
greenmail:
|
||||
@@ -188,7 +184,7 @@ services:
|
||||
- "127.0.0.1:3993:3993" # IMAPS
|
||||
|
||||
volumes:
|
||||
ltmodels:
|
||||
translatordata:
|
||||
pgdata:
|
||||
meilidata:
|
||||
blobs:
|
||||
|
||||
@@ -701,6 +701,23 @@ priority, so even the first checks after a restart are served from RAM. On
|
||||
mechanical disks that warm-up takes several minutes; an NVMe cache in front of
|
||||
the volume makes it — and any later cache misses — fast.
|
||||
|
||||
### Email translation (translator)
|
||||
|
||||
`translator` translates email in the reading pane, entirely on this server:
|
||||
Mozilla's Firefox Translations models (the ones Firefox uses for page
|
||||
translation) and fastText for telling which language a message is in.
|
||||
|
||||
- The first start downloads the languages in `TRANSLATOR_LANGUAGES` (default
|
||||
`pl,de,fr,es,it`; English is always there) into `$DATA_ROOT/translator`,
|
||||
about 35 MB per language and direction (~350 MB for the default set).
|
||||
- More languages are installed, and unused ones removed, from the **admin
|
||||
panel → Translation**. That list is kept in `$DATA_ROOT/translator`, so a
|
||||
restart does not undo it.
|
||||
- Models translate to and from English; other pairs go through English.
|
||||
- Capped at 2 GB of RAM and 2 CPUs (`TRANSLATOR_MEM_LIMIT`, `TRANSLATOR_CPUS`).
|
||||
- `./deploy/webmail.sh status` shows whether it answers and which languages
|
||||
are installed. `TRANSLATOR_URL=` (empty) in `.env` turns translation off.
|
||||
|
||||
Two NAS-specific realities worth knowing:
|
||||
|
||||
- **Disk hibernation won't happen.** Active accounts hold IMAP IDLE connections
|
||||
|
||||
@@ -8,9 +8,31 @@
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="ultimo-logo-signet.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="2.5488772"
|
||||
inkscape:cx="89.451151"
|
||||
inkscape:cy="-10.20057"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1403"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" /><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
@@ -26,9 +48,97 @@
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1788"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1788"
|
||||
sodipodi:nodetypes="cccsccc" /></mask><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient1769"
|
||||
id="linearGradient1789"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="37.71608"
|
||||
y1="123.22854"
|
||||
x2="27.709011"
|
||||
y2="135.24788" /><linearGradient
|
||||
id="linearGradient1769"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
|
||||
offset="0.00003701"
|
||||
id="stop1769" /><stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop1770" /></linearGradient><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1776-6"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1776-1"
|
||||
sodipodi:nodetypes="cccsccc" /></mask><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient13-6"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415" /><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient8"
|
||||
id="radialGradient816"
|
||||
cx="8.0343275"
|
||||
cy="13.452971"
|
||||
fx="8.0343275"
|
||||
fy="13.452971"
|
||||
r="16.678839"
|
||||
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient8"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#fa7367;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop8" /><stop
|
||||
style="stop-color:#fa7367;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop9" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient6"
|
||||
id="radialGradient815"
|
||||
cx="35.709007"
|
||||
cy="12.042645"
|
||||
fx="35.709007"
|
||||
fy="12.042645"
|
||||
r="16.740065"
|
||||
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient6"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#fab23b;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" /><stop
|
||||
style="stop-color:#fab23a;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop7" /></linearGradient></defs><g
|
||||
id="g816"
|
||||
transform="translate(0.128676,-0.079965)"><path
|
||||
id="path1-2-1-1"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z"
|
||||
sodipodi:nodetypes="cccccccccccssssccccccccssssssccccccccccccscc" /><path
|
||||
id="path1-2-1-9"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
|
||||
id="path1-2-1-7"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 9.0 KiB |
@@ -8,10 +8,48 @@
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="ultimo-logo.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.9327415"
|
||||
inkscape:cx="187.29872"
|
||||
inkscape:cy="135.81744"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1376"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" /><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient8"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#fa7367;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop8" /><stop
|
||||
style="stop-color:#fa7367;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop9" /></linearGradient><linearGradient
|
||||
id="linearGradient6"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#fab23b;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" /><stop
|
||||
style="stop-color:#fab23a;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop7" /></linearGradient><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
offset="0.20198774"
|
||||
@@ -26,13 +64,82 @@
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /><path
|
||||
style="font-weight:bold;font-size:44.7887px;line-height:1.5;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';letter-spacing:0.223944px;fill:#f74e6a;stroke-width:56.4265;stroke-linecap:round"
|
||||
d="m 73.758258,226.15765 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -28.35124 h 5.912108 v 28.1273 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403099,0.13436 h 4.971545 v 5.86732 z m 14.869774,0 q -1.701971,0 -3.090421,-0.85098 -1.388449,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -27.76899 h 5.86732 v 7.97239 h 10.122246 v 5.86732 H 88.314511 v 13.70534 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403098,0.13436 h 9.584782 v 5.86732 z m 13.705278,0 v 0 -25.97744 h 5.86732 v 25.97744 z m 0,-28.57519 v 0 -5.91211 h 5.86732 v 5.91211 z m 10.25654,28.57519 v 0 -25.97744 h 32.20307 q 1.74676,0 3.13521,0.85098 1.38845,0.85099 2.19465,2.23944 0.85098,1.38845 0.85098,3.09042 v 19.7966 h -5.82253 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.17916,-0.17915 -0.4031,-0.17915 h -9.27126 q -0.22395,0 -0.4031,0.17915 -0.13437,0.13437 -0.13437,0.35831 v 19.57266 h -5.91211 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 h -9.31605 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 19.57266 z m 50.07371,0 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.23944,-2.23944 -0.85098,-1.38845 -0.85098,-3.09042 v -13.61576 q 0,-1.70197 0.85098,-3.09042 0.85099,-1.38845 2.23944,-2.23944 1.38845,-0.85098 3.09042,-0.85098 h 14.01886 q 1.70197,0 3.09042,0.85098 1.43324,0.85099 2.23944,2.23944 0.85098,1.38845 0.85098,3.09042 v 13.61576 q 0,1.70197 -0.85098,3.09042 -0.8062,1.38845 -2.19465,2.23944 -1.38845,0.85098 -3.13521,0.85098 z m 0.22394,-5.86732 h 13.57098 q 0.22394,0 0.35831,-0.13436 0.17915,-0.17916 0.17915,-0.4031 v -13.16788 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 H 162.8875 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 13.16788 q 0,0.22394 0.13436,0.4031 0.17916,0.13436 0.4031,0.13436 z"
|
||||
id="text13"
|
||||
aria-label="ltimo" /></g></svg>
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient1769"
|
||||
id="linearGradient1789"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="37.71608"
|
||||
y1="123.22854"
|
||||
x2="27.709011"
|
||||
y2="135.24788" /><linearGradient
|
||||
id="linearGradient1769"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
|
||||
offset="0.00003701"
|
||||
id="stop1769" /><stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop1770" /></linearGradient><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
id="mask1788-1"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1788-2"
|
||||
sodipodi:nodetypes="cccsccc" /></mask><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
id="mask1776-6-6"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1776-1-2"
|
||||
sodipodi:nodetypes="cccsccc" /></mask><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient6"
|
||||
id="radialGradient815"
|
||||
cx="35.709007"
|
||||
cy="12.042645"
|
||||
fx="35.709007"
|
||||
fy="12.042645"
|
||||
r="16.740065"
|
||||
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
|
||||
gradientUnits="userSpaceOnUse" /><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient8"
|
||||
id="radialGradient816"
|
||||
cx="8.0343275"
|
||||
cy="13.452971"
|
||||
fx="8.0343275"
|
||||
fy="13.452971"
|
||||
r="16.678839"
|
||||
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
|
||||
gradientUnits="userSpaceOnUse" /></defs><g
|
||||
id="g816"><path
|
||||
id="path1-2-1"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z"
|
||||
sodipodi:nodetypes="cccccccccccssssccccccccssssssccccccccccccscc" /><path
|
||||
id="path1-2-1-9"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="M 3.6452555 7.14375 C 3.5159759 7.5278714 3.4447807 7.9357538 3.444751 8.3586629 L 3.444751 23.909652 C 3.444711 23.914652 3.4152451 27.753282 5.617745 31.514872 C 7.821705 35.278892 12.65614 38.897367 20.07061 38.897367 L 24.651208 38.897367 C 30.085144 38.897367 34.141887 36.930057 36.802405 34.39325 C 35.285228 33.174993 33.583741 31.797916 31.788757 30.3382 C 31.529773 30.540194 31.247156 30.732346 30.938163 30.908708 C 29.465893 31.749008 27.477765 32.239376 24.666195 32.239376 L 20.085079 32.239376 C 17.261269 32.239376 15.286427 31.759973 13.828097 30.935063 C 12.369767 30.110163 11.51428 28.931371 10.96884 27.999841 C 9.7721994 25.956191 9.7389404 23.928255 9.7389404 23.928255 L 9.7379069 23.914303 L 9.7379069 23.898283 L 9.7379069 12.201839 L 9.7379069 12.187887 L 9.7415243 12.179618 C 9.7928043 12.228318 9.92951 12.342748 9.98337 12.387358 L 15.408362 16.932817 L 20.923271 21.504114 C 21.007674 21.573954 21.108583 21.634775 21.221444 21.686532 C 16.15064 17.512631 11.679239 13.804108 9.750826 12.186336 C 7.4531167 10.290397 5.3700283 8.5696704 3.6452555 7.14375 z " /><path
|
||||
id="path1-2-1-7"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="M 41.226941 7.09104 C 39.508657 8.5115809 37.437441 10.222955 35.154443 12.106755 C 33.219453 13.730045 28.724331 17.458018 23.632149 21.649325 C 23.714466 21.60631 23.788932 21.557823 23.853841 21.504114 L 29.36875 16.932817 L 34.85627 12.358936 C 34.87617 12.342536 34.897015 12.325776 34.916215 12.310876 L 34.94412 12.289689 L 35.166846 12.09952 L 35.166846 23.697778 L 35.166846 23.726717 C 35.166846 23.726717 35.100288 25.808681 33.855298 27.910441 C 33.283278 28.876161 32.409906 30.068398 30.937646 30.908708 C 29.465376 31.749008 27.477248 32.239376 24.665678 32.239376 L 20.084562 32.239376 C 17.260752 32.239376 15.28591 31.759973 13.82758 30.935063 C 13.518898 30.760458 13.236992 30.570319 12.979569 30.369722 C 11.174016 31.837687 9.466344 33.219415 7.9488688 34.437174 C 10.591399 36.955726 14.638319 38.897367 20.070093 38.897367 L 24.650692 38.897367 C 32.068392 38.897367 36.920103 35.231484 39.156783 31.455444 C 41.393463 27.679404 41.426929 23.820251 41.426929 23.820251 L 41.428996 23.786145 L 41.428996 8.3007853 C 41.429125 7.8798785 41.357396 7.4736084 41.226941 7.09104 z " /></g><text
|
||||
xml:space="preserve"
|
||||
style="font-size:44.7887px;line-height:1.5;font-family:Roboto;-inkscape-font-specification:Roboto;letter-spacing:0.223944px;writing-mode:lr-tb;direction:ltr;fill:#f74e6a;fill-opacity:1;stroke:#ff0000;stroke-width:56.4265;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
|
||||
x="43.547977"
|
||||
y="38.744164"
|
||||
id="text13-7"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan13-7"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';fill:#f74e6a;fill-opacity:1;stroke:none;stroke-width:56.4265"
|
||||
x="43.547977"
|
||||
y="38.744164">ltimo</tspan></text></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,14 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512">
|
||||
<title>Ultimo Email</title>
|
||||
<desc>Ultimo Email logo, for BIMI (SVG Tiny Portable/Secure)</desc>
|
||||
<defs>
|
||||
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="44.1352" y1="192.6008" x2="44.1352" y2="224.5123">
|
||||
<stop offset="0.202" stop-color="#f89629"/>
|
||||
<stop offset="1" stop-color="#f74176"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<defs><linearGradient id="g0" gradientUnits="userSpaceOnUse" x1="22.4348" y1="5.1873" x2="22.4348" y2="37.0988"><stop offset="0.2020" stop-color="#f89629" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#f74176" stop-opacity="1.0000"/></linearGradient><radialGradient id="g1" gradientUnits="userSpaceOnUse" cx="8.0343" cy="13.4530" r="16.6788"><stop offset="0.0000" stop-color="#fa7367" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#fa7367" stop-opacity="0.0000"/></radialGradient><radialGradient id="g2" gradientUnits="userSpaceOnUse" cx="35.7090" cy="12.0426" r="16.7401"><stop offset="0.0000" stop-color="#fab23b" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#fab23a" stop-opacity="0.0000"/></radialGradient></defs>
|
||||
<rect width="512" height="512" fill="#ffffff"/>
|
||||
<g transform="translate(97.2800,104.2299) scale(7.063024) translate(-21.70051,-187.41349)">
|
||||
<path fill="url(#g)" d="m 58.875622,191.58757 c -2.152336,-0.00074 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-0.00007 -0.0835,0.00059 -0.125155,0.002 v -2.78918 c -0.000166,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,0.00018 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -0.000039,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 0.0007,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 0.000782,0.001 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 0.000782,-0.00082 z"/>
|
||||
</g>
|
||||
<g transform="matrix(7.081810,0,0,7.081810,96.1936,104.0546)"><g transform="matrix(1,0,0,1,0.128676,-0.079965)"><path fill="url(#g0)" d="M37.1752,4.1741C35.0228,4.1733 33.1074,6.0213 33.1081,8.3007L33.1081,11.1528C33.0351,11.1478 32.9615,11.1458 32.8879,11.1458L22.3878,11.1458L11.8892,11.1458C11.8474,11.1457 11.8057,11.1464 11.7641,11.1478L11.7641,8.3586C11.7639,6.0805 9.8048,4.2338 7.6537,4.2337C5.5026,4.2339 3.4449,6.0805 3.4448,8.3586L3.4448,23.9099C3.4447,23.9149 3.4153,27.7533 5.6178,31.5149C7.8217,35.2789 12.6560,38.8974 20.0704,38.8974L24.6511,38.8974C32.0688,38.8974 36.9203,35.2313 39.1570,31.4552C41.3937,27.6792 41.4274,23.8204 41.4274,23.8204L41.4294,23.7860L41.4294,8.3006C41.4301,6.0219 39.3273,4.1742 37.1756,4.1740ZM35.1673,12.0993L35.1673,23.6979L35.1673,23.7265C35.1673,23.7265 35.1009,25.8089 33.8559,27.9106C33.2839,28.8763 32.4105,30.0682 30.9382,30.9085C29.4659,31.7488 27.4775,32.2393 24.6660,32.2393L20.0849,32.2393C17.2611,32.2393 15.2863,31.7600 13.8279,30.9350C12.3696,30.1101 11.5144,28.9316 10.9689,28.0001C9.7723,25.9564 9.7389,23.9282 9.7389,23.9282L9.7379,23.9141L9.7379,23.8984L9.7379,12.2020L9.7381,12.1878L9.7416,12.1798C9.7929,12.2285 9.9297,12.3428 9.9835,12.3874L15.4082,16.9326L20.9233,21.5040C21.5752,22.0435 23.2023,22.0435 23.8542,21.5040L29.3693,16.9326L34.8566,12.3591C34.8765,12.3427 34.8975,12.3260 34.9167,12.3111L34.9443,12.2896Z"/><g transform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"><path fill="url(#g1)" d="M27.1856,17.7752C27.6455,17.3736 27.9083,16.9462 27.9286,16.5020L28.6733,0.1670C28.6737,0.1618 28.9581,-3.8700 21.6154,-7.8536C14.2678,-11.8398 -2.0714,-15.7119 -27.3962,-15.8211L-43.0417,-15.8886C-61.6019,-15.9686 -75.5523,-13.9619 -84.7610,-11.3363C-79.6373,-10.0343 -73.8916,-8.5628 -67.8306,-7.0030C-66.9363,-7.2114 -65.9618,-7.4091 -64.8980,-7.5898C-59.8291,-8.4508 -53.0149,-8.9366 -43.4117,-8.8952L-27.7645,-8.8277C-18.1195,-8.7861 -11.3972,-8.2535 -6.4556,-7.3655C-1.5141,-6.4775 1.3515,-5.2267 3.1699,-4.2402C7.1593,-2.0759 7.1758,0.0548 7.1758,0.0548L7.1786,0.0694L7.1778,0.0863L6.6177,12.3724L6.6171,12.3870L6.6043,12.3957C6.4315,12.3438 5.9700,12.2216 5.7882,12.1739L-12.5237,7.3194L-31.1415,2.4364C-31.4264,2.3618 -31.7682,2.2965 -32.1512,2.2404C-15.0312,6.6994 0.0637,10.6607 6.5729,12.3885C14.3301,14.4138 21.3627,16.2520 27.1856,17.7752Z"/></g><g transform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.973013)"><path fill="url(#g2)" d="M56.9506,6.2383C50.3245,7.7408 42.3373,9.5509 33.5334,11.5434C26.0725,13.2601 8.7387,17.2029 -10.8988,21.6363C-10.5803,21.5905 -10.2927,21.5390 -10.0424,21.4822L11.2240,16.6473L32.3838,11.8101C32.4605,11.7927 32.5409,11.7750 32.6150,11.7592L32.7226,11.7368L33.5813,11.5357L34.0382,23.6546L34.0393,23.6848C34.0393,23.6848 33.8625,25.8609 29.1038,28.0702C26.9173,29.0853 23.5679,30.3403 17.8757,31.2340C12.1834,32.1276 4.4713,32.6610 -6.4624,32.6907L-24.2775,32.7392C-35.2588,32.7691 -42.9575,32.2891 -48.6611,31.4426C-49.8684,31.2634 -50.9722,31.0677 -51.9812,30.8609C-58.9448,32.4138 -65.5312,33.8756 -71.3844,35.1641C-61.0089,37.7678 -45.1947,39.7537 -24.0715,39.6962L-6.2584,39.6477C22.5876,39.5692 41.3106,35.6874 49.8599,31.7182C58.4092,27.7490 58.3873,23.7163 58.3873,23.7163L58.3941,23.6806L57.7841,7.5002C57.7679,7.0604 57.4730,6.6366 56.9506,6.2383Z"/></g></g></g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
@@ -7,7 +7,7 @@
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"background_color": "#1b1e20",
|
||||
"theme_color": "#f3f4f6",
|
||||
"categories": ["productivity", "business"],
|
||||
"icons": [
|
||||
|
||||
@@ -26,9 +26,87 @@
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1788"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1788" /></mask><linearGradient
|
||||
xlink:href="#linearGradient1769"
|
||||
id="linearGradient1789"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="37.71608"
|
||||
y1="123.22854"
|
||||
x2="27.709011"
|
||||
y2="135.24788" /><linearGradient
|
||||
id="linearGradient1769"><stop
|
||||
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
|
||||
offset="0.00003701"
|
||||
id="stop1769" /><stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop1770" /></linearGradient><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1776-6"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1776-1" /></mask><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient13-6"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415" /><radialGradient
|
||||
xlink:href="#linearGradient8"
|
||||
id="radialGradient816"
|
||||
cx="8.0343275"
|
||||
cy="13.452971"
|
||||
fx="8.0343275"
|
||||
fy="13.452971"
|
||||
r="16.678839"
|
||||
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient8"><stop
|
||||
style="stop-color:#fa7367;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop8" /><stop
|
||||
style="stop-color:#fa7367;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop9" /></linearGradient><radialGradient
|
||||
xlink:href="#linearGradient6"
|
||||
id="radialGradient815"
|
||||
cx="35.709007"
|
||||
cy="12.042645"
|
||||
fx="35.709007"
|
||||
fy="12.042645"
|
||||
r="16.740065"
|
||||
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient6"><stop
|
||||
style="stop-color:#fab23b;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" /><stop
|
||||
style="stop-color:#fab23a;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop7" /></linearGradient></defs><g
|
||||
id="g816"
|
||||
transform="translate(0.128676,-0.079965)"><path
|
||||
id="path1-2-1-1"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z" /><path
|
||||
id="path1-2-1-9"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
|
||||
id="path1-2-1-7"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -2,10 +2,10 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { languageName, useLocale, useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
import type { AdminMail, AdminUser } from "../lib/types";
|
||||
import type { AdminMail, AdminTranslation, AdminUser } from "../lib/types";
|
||||
import { useConfirm } from "../components/AppDialog";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
@@ -44,8 +44,9 @@ export function AdminApp() {
|
||||
<Users selfId={me.data.id} />
|
||||
</div>
|
||||
{/* The divider runs the full height of the taller column. */}
|
||||
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
|
||||
<div className="min-w-0 space-y-10 lg:border-l lg:border-app-border lg:pl-12">
|
||||
<SystemMail />
|
||||
<Translation />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -287,6 +288,136 @@ function Panel({ handle }: { handle: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Email translation's languages: installed ones (removable, but English),
|
||||
* downloads in progress, and any language Mozilla has models for to add. */
|
||||
function Translation() {
|
||||
const t = useT();
|
||||
const { locale } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const [adding, setAdding] = useState("");
|
||||
const info = useQuery({
|
||||
queryKey: ["adminTranslation"],
|
||||
queryFn: api.adminTranslation,
|
||||
// While something downloads, follow its progress.
|
||||
refetchInterval: (q) =>
|
||||
Object.keys((q.state.data as AdminTranslation | undefined)?.installing ?? {}).length ? 1500 : false,
|
||||
});
|
||||
const done = (data: AdminTranslation) => {
|
||||
queryClient.setQueryData(["adminTranslation"], data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["translation-languages"] });
|
||||
};
|
||||
const install = useMutation({
|
||||
mutationFn: api.installTranslationLanguage,
|
||||
onSuccess: (data) => {
|
||||
done(data);
|
||||
setAdding("");
|
||||
},
|
||||
});
|
||||
const remove = useMutation({ mutationFn: api.removeTranslationLanguage, onSuccess: done });
|
||||
|
||||
const name = (code: string) => languageName(code, locale);
|
||||
const data = info.data;
|
||||
const busy = install.isPending || remove.isPending;
|
||||
const installing = Object.entries(data?.installing ?? {});
|
||||
const addable = (data?.available ?? [])
|
||||
.filter((c) => !data?.installed.includes(c) && !(c in (data?.installing ?? {})))
|
||||
.sort((a, b) => name(a).localeCompare(name(b), locale));
|
||||
const error = (install.error ?? remove.error) as unknown;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("admin.translation")}</h2>
|
||||
<p className="text-xs text-app-faint">{t("admin.translationHelp")}</p>
|
||||
{info.error instanceof ApiError && <p className="text-sm text-red-500">{info.error.message}</p>}
|
||||
{data && !data.enabled && <p className="text-sm text-amber-600">{t("admin.translationOff")}</p>}
|
||||
{data?.enabled && !data.reachable && (
|
||||
<p className="text-sm text-amber-600">{t("admin.translationUnreachable")}</p>
|
||||
)}
|
||||
{data?.reachable && (
|
||||
<>
|
||||
<ul className="divide-y divide-app-border rounded border border-app-border">
|
||||
{[...data.installed]
|
||||
.sort((a, b) => name(a).localeCompare(name(b), locale))
|
||||
.map((code) => (
|
||||
<li key={code} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span>
|
||||
{name(code)} <span className="text-xs text-app-faint">{code}</span>
|
||||
</span>
|
||||
{code === "en" ? (
|
||||
<span className="text-xs text-app-faint">{t("admin.translationAlways")}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="rounded border border-app-border px-2 py-0.5 text-xs hover:bg-app-hover disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.translationRemoveTitle", { language: name(code) }),
|
||||
message: t("admin.translationRemoveConfirm"),
|
||||
confirmLabel: t("admin.translationRemove"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) remove.mutate(code);
|
||||
}}
|
||||
>
|
||||
{t("admin.translationRemove")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{installing.map(([code, p]) => (
|
||||
<li key={code} className="flex items-center justify-between px-3 py-2 text-sm text-app-muted">
|
||||
<span>{name(code)}</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{t("admin.translationInstalling", {
|
||||
percent: p.total ? Math.floor((p.done / p.total) * 100) : 0,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{Object.entries(data.errors).map(([code, why]) => (
|
||||
<p key={code} className="text-xs text-red-500">
|
||||
{t("admin.translationFailed", { language: name(code) })} {why}
|
||||
</p>
|
||||
))}
|
||||
{addable.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm"
|
||||
value={adding}
|
||||
onChange={(e) => setAdding(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.translationChoose")}</option>
|
||||
{addable.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{name(code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!adding || busy}
|
||||
className="rounded bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
onClick={() => install.mutate(adding)}
|
||||
>
|
||||
{t("admin.translationInstall")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
data.available.length === 0 && (
|
||||
<p className="text-xs text-app-faint">{t("admin.translationCatalogueOffline")}</p>
|
||||
)
|
||||
)}
|
||||
{error instanceof ApiError && <p className="text-xs text-red-500">{error.message}</p>}
|
||||
</>
|
||||
)}
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
|
||||
|
||||
/** The SMTP account the server sends password-reset links from. */
|
||||
|
||||
@@ -478,7 +478,7 @@ function MessageItem({
|
||||
: [];
|
||||
const offerTranslate =
|
||||
translateCodes.length > 0 &&
|
||||
(translateTo !== null || needsTranslation(language, locale));
|
||||
(translateTo !== null || needsTranslation(language, locale, translateCodes));
|
||||
const target = translateCodes.find((c) => sameLanguage(c, locale)) ?? null;
|
||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||
<button
|
||||
|
||||
@@ -691,6 +691,20 @@ export const en = {
|
||||
"admin.testTo": "Send a test email to",
|
||||
"admin.sendTest": "Send test",
|
||||
"admin.testSent": "Test email sent.",
|
||||
"admin.translation": "Email translation",
|
||||
"admin.translationHelp":
|
||||
"Languages email can be translated between, all on this server (Mozilla's Firefox Translations models). Each language is about 70 MB: models to and from English, through which other pairs go.",
|
||||
"admin.translationOff": "Translation is off on this server (TRANSLATOR_URL is empty).",
|
||||
"admin.translationUnreachable": "The translation service is not answering. It may still be starting.",
|
||||
"admin.translationAlways": "always installed",
|
||||
"admin.translationRemove": "Remove",
|
||||
"admin.translationRemoveTitle": "Remove {language}?",
|
||||
"admin.translationRemoveConfirm": "Its models are deleted. Mail in this language can no longer be translated until it is installed again.",
|
||||
"admin.translationInstalling": "downloading… {percent}%",
|
||||
"admin.translationFailed": "Installing {language} failed:",
|
||||
"admin.translationChoose": "Add a language…",
|
||||
"admin.translationInstall": "Install",
|
||||
"admin.translationCatalogueOffline": "Mozilla's list of languages can't be reached right now, so no more can be added.",
|
||||
"admin.users": "Users",
|
||||
"admin.userSearch": "Search by login, email address or name…",
|
||||
"admin.noUsers": "No users match.",
|
||||
|
||||
@@ -68,9 +68,14 @@ export function sameLanguage(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
/** Whether a message in ``languages`` ("zh-Hans,pl,en", as the server found
|
||||
* them) has a part that is not in ``locale``: then it can be translated. */
|
||||
export function needsTranslation(languages: string, locale: string): boolean {
|
||||
return splitLanguages(languages).some((code) => !sameLanguage(code, locale));
|
||||
* them) has a part that is not in ``locale`` but is in ``installed`` (the
|
||||
* languages translation has): then it can be translated. */
|
||||
export function needsTranslation(languages: string, locale: string, installed: string[]): boolean {
|
||||
return splitLanguages(languages).some(
|
||||
(code) =>
|
||||
!sameLanguage(code, locale) &&
|
||||
installed.some((have) => have === code || (!have.includes("-") && sameLanguage(have, code))),
|
||||
);
|
||||
}
|
||||
|
||||
/** "zh-Hans,pl,en" → ["zh-Hans", "pl", "en"]. */
|
||||
|
||||
@@ -702,6 +702,20 @@ export const pl: Catalog = {
|
||||
"admin.testTo": "Wyślij testowy e-mail do",
|
||||
"admin.sendTest": "Wyślij test",
|
||||
"admin.testSent": "Wysłano testowy e-mail.",
|
||||
"admin.translation": "Tłumaczenie poczty",
|
||||
"admin.translationHelp":
|
||||
"Języki, między którymi można tłumaczyć pocztę, w całości na tym serwerze (modele Firefox Translations od Mozilli). Każdy język to około 70 MB: modele z angielskiego i na angielski, przez który idą pozostałe pary.",
|
||||
"admin.translationOff": "Tłumaczenie jest wyłączone na tym serwerze (TRANSLATOR_URL jest pusty).",
|
||||
"admin.translationUnreachable": "Usługa tłumaczenia nie odpowiada. Może jeszcze się uruchamia.",
|
||||
"admin.translationAlways": "zawsze zainstalowany",
|
||||
"admin.translationRemove": "Usuń",
|
||||
"admin.translationRemoveTitle": "Usunąć: {language}?",
|
||||
"admin.translationRemoveConfirm": "Jego modele zostaną usunięte. Poczty w tym języku nie będzie można tłumaczyć, dopóki nie zostanie zainstalowany ponownie.",
|
||||
"admin.translationInstalling": "pobieranie… {percent}%",
|
||||
"admin.translationFailed": "Instalacja nie powiodła się: {language}.",
|
||||
"admin.translationChoose": "Dodaj język…",
|
||||
"admin.translationInstall": "Zainstaluj",
|
||||
"admin.translationCatalogueOffline": "Lista języków Mozilli jest teraz niedostępna, więc nie można dodać kolejnych.",
|
||||
"admin.users": "Użytkownicy",
|
||||
"admin.userSearch": "Szukaj po loginie, adresie e-mail lub nazwie…",
|
||||
"admin.noUsers": "Brak pasujących użytkowników.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AppPassword,
|
||||
AccountDetail,
|
||||
AdminMail,
|
||||
AdminTranslation,
|
||||
AdminSettings,
|
||||
AdminUser,
|
||||
AccountOut,
|
||||
@@ -136,6 +137,11 @@ export const api = {
|
||||
deleteAdminUser: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
|
||||
adminMail: () => apiFetch<AdminMail>("/admin/mail"),
|
||||
adminTranslation: () => apiFetch<AdminTranslation>("/admin/translation"),
|
||||
installTranslationLanguage: (code: string) =>
|
||||
apiFetch<AdminTranslation>(`/admin/translation/languages/${code}`, { method: "POST" }),
|
||||
removeTranslationLanguage: (code: string) =>
|
||||
apiFetch<AdminTranslation>(`/admin/translation/languages/${code}`, { method: "DELETE" }),
|
||||
// PUT merges: omit smtp_password to keep the stored one, "" to remove it.
|
||||
saveAdminMail: (body: Partial<Omit<AdminMail, "has_password" | "configured">> & { smtp_password?: string }) =>
|
||||
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
|
||||
|
||||
@@ -42,6 +42,18 @@ export interface AdminMail {
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
/** Email translation's languages on the translator service (admin panel). */
|
||||
export interface AdminTranslation {
|
||||
enabled: boolean;
|
||||
reachable: boolean;
|
||||
installed: string[];
|
||||
/** Downloads in progress: bytes done of total (total 0 until known). */
|
||||
installing: Record<string, { done: number; total: number }>;
|
||||
/** Languages Mozilla has models for; empty when the catalogue is out of reach. */
|
||||
available: string[];
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registration_open: boolean;
|
||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||
|
||||
@@ -26,9 +26,87 @@
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1788"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1788" /></mask><linearGradient
|
||||
xlink:href="#linearGradient1769"
|
||||
id="linearGradient1789"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="37.71608"
|
||||
y1="123.22854"
|
||||
x2="27.709011"
|
||||
y2="135.24788" /><linearGradient
|
||||
id="linearGradient1769"><stop
|
||||
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
|
||||
offset="0.00003701"
|
||||
id="stop1769" /><stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop1770" /></linearGradient><mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-1000"
|
||||
y="-1000"
|
||||
width="3000"
|
||||
height="3000"
|
||||
id="mask1776-6"><path
|
||||
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
|
||||
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
|
||||
id="path1776-1" /></mask><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient13-6"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415" /><radialGradient
|
||||
xlink:href="#linearGradient8"
|
||||
id="radialGradient816"
|
||||
cx="8.0343275"
|
||||
cy="13.452971"
|
||||
fx="8.0343275"
|
||||
fy="13.452971"
|
||||
r="16.678839"
|
||||
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient8"><stop
|
||||
style="stop-color:#fa7367;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop8" /><stop
|
||||
style="stop-color:#fa7367;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop9" /></linearGradient><radialGradient
|
||||
xlink:href="#linearGradient6"
|
||||
id="radialGradient815"
|
||||
cx="35.709007"
|
||||
cy="12.042645"
|
||||
fx="35.709007"
|
||||
fy="12.042645"
|
||||
r="16.740065"
|
||||
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient6"><stop
|
||||
style="stop-color:#fab23b;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" /><stop
|
||||
style="stop-color:#fab23a;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop7" /></linearGradient></defs><g
|
||||
id="g816"
|
||||
transform="translate(0.128676,-0.079965)"><path
|
||||
id="path1-2-1-1"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z" /><path
|
||||
id="path1-2-1-9"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
|
||||
id="path1-2-1-7"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,6 @@
|
||||
# The translation service (translator.py). Python 3.10: bergamot's newest build.
|
||||
bergamot==0.4.5
|
||||
fasttext-predict==0.9.2.4
|
||||
fastapi==0.139.2
|
||||
pydantic==2.13.4
|
||||
uvicorn==0.51.0
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Ultimo's translation service: Mozilla's Firefox Translations models (the
|
||||
Bergamot engine) and fastText language identification, behind a small HTTP
|
||||
API that only the webmail API calls (backend/app/translation.py), on the
|
||||
internal network. A message's text never leaves the server.
|
||||
|
||||
- Firefox's models translate to and from English; any other pair goes
|
||||
through English (Polish → English → German). So a language is installed as
|
||||
its two models, to and from English, and English is always there.
|
||||
- What is installed is kept in ``$DATA_DIR/state.json``. The first start
|
||||
installs ``TRANSLATOR_LANGUAGES``; after that the admin panel adds and
|
||||
removes languages (``POST``/``DELETE /languages/{code}``).
|
||||
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
|
||||
file checked against its SHA-256; only release models (no test builds).
|
||||
- Language identification: fastText's lid.176 (176 languages), baked into
|
||||
the image.
|
||||
|
||||
API (JSON):
|
||||
GET /health
|
||||
GET /languages installed / installing / available, errors
|
||||
POST /languages/{code} install (in the background)
|
||||
DELETE /languages/{code} remove
|
||||
POST /detect {"q": str | [str]} → language(s)
|
||||
POST /translate {"q": str | [str], "source", "target"} → translation(s)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fasttext
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def _load_engine() -> Any:
|
||||
"""The Bergamot engine: only its compiled module. Importing the bergamot
|
||||
package itself fetches model catalogues from translatelocally.com and
|
||||
OPUS on every start (and fails without them); we load our own models."""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("bergamot")
|
||||
if spec is None or not spec.submodule_search_locations:
|
||||
raise ImportError("the bergamot package is not installed")
|
||||
folder = Path(next(iter(spec.submodule_search_locations)))
|
||||
path = next(folder.glob("_bergamot*.so"))
|
||||
loader = importlib.machinery.ExtensionFileLoader("_bergamot", str(path))
|
||||
module_spec = importlib.util.spec_from_loader("_bergamot", loader)
|
||||
assert module_spec is not None
|
||||
module = importlib.util.module_from_spec(module_spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
bergamot = _load_engine()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("translator")
|
||||
|
||||
DATA = Path(os.environ.get("DATA_DIR", "/data"))
|
||||
MODELS = DATA / "models"
|
||||
STATE = DATA / "state.json"
|
||||
LID_MODEL = os.environ.get("LID_MODEL", "/opt/lid.176.ftz")
|
||||
DEFAULT_LANGUAGES = [
|
||||
c.strip() for c in os.environ.get("TRANSLATOR_LANGUAGES", "pl,de,fr,es,it").split(",")
|
||||
]
|
||||
THREADS = int(os.environ.get("TRANSLATOR_THREADS", "2"))
|
||||
RECORDS_URL = os.environ.get(
|
||||
"MODELS_RECORDS_URL",
|
||||
"https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/"
|
||||
"translations-models/records",
|
||||
)
|
||||
ATTACHMENTS_URL = os.environ.get(
|
||||
"MODELS_ATTACHMENTS_URL", "https://firefox-settings-attachments.cdn.mozilla.net/"
|
||||
)
|
||||
PIVOT = "en"
|
||||
_RECORDS_TTL = 3600
|
||||
_CODE = re.compile(r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
|
||||
|
||||
_service = bergamot.Service(bergamot.ServiceConfig(numWorkers=THREADS, logLevel="off"))
|
||||
_lid = fasttext.load_model(LID_MODEL)
|
||||
_lock = threading.Lock() # state, loaded models, installs
|
||||
_loaded: dict[str, Any] = {} # "en-pl" → bergamot model
|
||||
_installing: dict[str, dict[str, int]] = {} # "pl" → {"done": bytes, "total": bytes}
|
||||
_errors: dict[str, str] = {} # "pl" → why the last install failed
|
||||
_records: tuple[float, list[dict[str, Any]]] = (0.0, [])
|
||||
|
||||
|
||||
# --- what is installed --------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(STATE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _installed() -> list[str]:
|
||||
state = _read_state() or {}
|
||||
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
|
||||
|
||||
|
||||
def _write_installed(codes: list[str]) -> None:
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"languages": sorted(set(codes) - {PIVOT})}))
|
||||
tmp.replace(STATE)
|
||||
|
||||
|
||||
# --- Mozilla's model catalogue -----------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch(url: str, timeout: float = 60) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ultimo-translator"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _catalogue() -> list[dict[str, Any]]:
|
||||
"""Mozilla's model records (cached for an hour)."""
|
||||
global _records
|
||||
stamp, cached = _records
|
||||
if cached and time.monotonic() - stamp < _RECORDS_TTL:
|
||||
return cached
|
||||
data = json.loads(_fetch(RECORDS_URL))["data"]
|
||||
_records = (time.monotonic(), data)
|
||||
return data
|
||||
|
||||
|
||||
def _version(text: str) -> tuple[int, ...] | None:
|
||||
""" "2.1" → (2, 1); test builds ("2.1a1") → None."""
|
||||
return tuple(int(x) for x in text.split(".")) if re.fullmatch(r"\d+(\.\d+)*", text) else None
|
||||
|
||||
|
||||
def _release_files(source: str, target: str) -> list[dict[str, Any]]:
|
||||
"""The newest release model of one direction: its files' records."""
|
||||
by_version: dict[tuple[int, ...], list[dict[str, Any]]] = {}
|
||||
for r in _catalogue():
|
||||
if r.get("fromLang") != source or r.get("toLang") != target:
|
||||
continue
|
||||
if r.get("filter_expression"):
|
||||
continue # a test build (Nightly only)
|
||||
v = _version(str(r.get("version", "")))
|
||||
if v is not None:
|
||||
by_version.setdefault(v, []).append(r)
|
||||
if not by_version:
|
||||
raise LookupError(f"no model for {source} → {target}")
|
||||
return by_version[max(by_version)]
|
||||
|
||||
|
||||
def _available() -> list[str]:
|
||||
"""Languages Mozilla has release models for, both ways with English."""
|
||||
pairs: set[tuple[str, str]] = set()
|
||||
for r in _catalogue():
|
||||
if not r.get("filter_expression") and _version(str(r.get("version", ""))):
|
||||
pairs.add((r["fromLang"], r["toLang"]))
|
||||
return sorted({a for a, b in pairs if b == PIVOT and (PIVOT, a) in pairs} | {PIVOT})
|
||||
|
||||
|
||||
# --- installing ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model_dir(source: str, target: str) -> Path:
|
||||
return MODELS / f"{source}-{target}"
|
||||
|
||||
|
||||
def _install_direction(code: str, source: str, target: str) -> None:
|
||||
files = _release_files(source, target)
|
||||
final = _model_dir(source, target)
|
||||
tmp = final.with_name(final.name + ".partial")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
tmp.mkdir(parents=True)
|
||||
for r in files:
|
||||
att = r["attachment"]
|
||||
blob = _fetch(ATTACHMENTS_URL + att["location"], timeout=600)
|
||||
if hashlib.sha256(blob).hexdigest() != att["hash"]:
|
||||
raise ValueError(f"{att['filename']}: checksum mismatch")
|
||||
(tmp / att["filename"]).write_bytes(blob)
|
||||
with _lock:
|
||||
_installing[code]["done"] += len(blob)
|
||||
(tmp / "files.json").write_text(
|
||||
json.dumps(
|
||||
{r["fileType"]: r["attachment"]["filename"] for r in files}
|
||||
| {"version": files[0]["version"]}
|
||||
)
|
||||
)
|
||||
shutil.rmtree(final, ignore_errors=True)
|
||||
tmp.replace(final)
|
||||
|
||||
|
||||
def _install(code: str) -> None:
|
||||
"""Both directions of ``code`` with English, then mark it installed."""
|
||||
try:
|
||||
directions = [(code, PIVOT), (PIVOT, code)]
|
||||
total = sum(r["attachment"]["size"] for s, t in directions for r in _release_files(s, t))
|
||||
with _lock:
|
||||
_installing[code]["total"] = total
|
||||
for source, target in directions:
|
||||
_install_direction(code, source, target)
|
||||
with _lock:
|
||||
_write_installed(_installed() + [code])
|
||||
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
||||
_loaded.pop(pair, None) # a reinstall: load the new files
|
||||
_errors.pop(code, None)
|
||||
log.info("installed %s", code)
|
||||
except Exception as exc: # noqa: BLE001 — reported in /languages, tried again by hand
|
||||
log.warning("installing %s failed: %s", code, exc)
|
||||
with _lock:
|
||||
_errors[code] = str(exc)[:300]
|
||||
finally:
|
||||
with _lock:
|
||||
_installing.pop(code, None)
|
||||
|
||||
|
||||
def _start_install(code: str) -> bool:
|
||||
with _lock:
|
||||
if code in _installing:
|
||||
return False
|
||||
_installing[code] = {"done": 0, "total": 0}
|
||||
threading.Thread(target=_install, args=(code,), daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def _first_start() -> None:
|
||||
"""The first start installs the default languages, one after another."""
|
||||
if _read_state() is not None:
|
||||
return
|
||||
_write_installed([])
|
||||
|
||||
def run() -> None:
|
||||
for code in DEFAULT_LANGUAGES:
|
||||
if code and code != PIVOT:
|
||||
with _lock:
|
||||
_installing[code] = {"done": 0, "total": 0}
|
||||
_install(code)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
_first_start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Ultimo translator", docs_url=None, redoc_url=None, lifespan=_lifespan)
|
||||
|
||||
|
||||
# --- translating -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config(directory: Path) -> str:
|
||||
files = json.loads((directory / "files.json").read_text())
|
||||
model = files["model"]
|
||||
vocabs = (
|
||||
[files["vocab"], files["vocab"]]
|
||||
if "vocab" in files
|
||||
else [files["srcvocab"], files["trgvocab"]]
|
||||
)
|
||||
# The models are 8-bit; their file name says which kind (as Firefox decides).
|
||||
precision = "int8shiftAlphaAll" if model.endswith("alphas.bin") else "int8shiftAll"
|
||||
lines = [
|
||||
"relative-paths: false",
|
||||
"models:",
|
||||
f" - {directory / model}",
|
||||
"vocabs:",
|
||||
*[f" - {directory / v}" for v in vocabs],
|
||||
"beam-size: 1",
|
||||
"normalize: 1.0",
|
||||
"word-penalty: 0",
|
||||
"mini-batch: 64",
|
||||
"maxi-batch: 1000",
|
||||
"maxi-batch-sort: src",
|
||||
"workspace: 2000",
|
||||
"max-length-factor: 2.5",
|
||||
"max-length-break: 128",
|
||||
"mini-batch-words: 1024",
|
||||
"skip-cost: true",
|
||||
"quiet: true",
|
||||
f"gemm-precision: {precision}",
|
||||
]
|
||||
if "lex" in files:
|
||||
lines += ["shortlist:", f" - {directory / files['lex']}", " - false"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _model(source: str, target: str) -> Any:
|
||||
pair = f"{source}-{target}"
|
||||
with _lock:
|
||||
if pair not in _loaded:
|
||||
directory = _model_dir(source, target)
|
||||
if not (directory / "files.json").exists():
|
||||
raise HTTPException(400, f"{source} → {target} is not installed")
|
||||
_loaded[pair] = _service.modelFromConfig(_config(directory))
|
||||
return _loaded[pair]
|
||||
|
||||
|
||||
_OPTIONS = bergamot.ResponseOptions(alignment=False, qualityScores=False, HTML=False)
|
||||
|
||||
|
||||
def _translate(texts: list[str], source: str, target: str) -> list[str]:
|
||||
todo = [i for i, t in enumerate(texts) if t.strip()]
|
||||
out = list(texts)
|
||||
if not todo or source == target:
|
||||
return out
|
||||
batch = [texts[i] for i in todo]
|
||||
steps = [(source, target)] if PIVOT in (source, target) else [(source, PIVOT), (PIVOT, target)]
|
||||
for s, t in steps:
|
||||
model = _model(s, t)
|
||||
batch = [
|
||||
r.target.text for r in _service.translate(model, bergamot.VectorString(batch), _OPTIONS)
|
||||
]
|
||||
for i, text in zip(todo, batch, strict=True):
|
||||
out[i] = text
|
||||
return out
|
||||
|
||||
|
||||
# --- identifying languages -----------------------------------------------------------------
|
||||
|
||||
# fastText's labels where Firefox's codes differ.
|
||||
_LID_CODES = {"zh": "zh-Hans", "no": "nb", "iw": "he", "fil": "tl"}
|
||||
# Signs only traditional Chinese uses (a few common ones): tell zh-Hant apart.
|
||||
_TRADITIONAL = set("們這個來說會對時國過還後點學東車長門開關問電話員")
|
||||
|
||||
|
||||
def _detect(text: str) -> dict[str, Any]:
|
||||
sample = " ".join(text.split())[:2000]
|
||||
if not sample:
|
||||
return {"language": "", "confidence": 0.0}
|
||||
labels, scores = _lid.predict(sample, k=1)
|
||||
code = labels[0].replace("__label__", "") if labels else ""
|
||||
code = _LID_CODES.get(code, code)
|
||||
if code == "zh-Hans" and sum(c in _TRADITIONAL for c in sample) >= 2:
|
||||
code = "zh-Hant"
|
||||
return {"language": code, "confidence": round(float(scores[0]) * 100, 1) if scores else 0.0}
|
||||
|
||||
|
||||
# --- the API ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DetectIn(BaseModel):
|
||||
q: str | list[str]
|
||||
|
||||
|
||||
class TranslateIn(BaseModel):
|
||||
q: str | list[str]
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/languages")
|
||||
def languages() -> dict[str, Any]:
|
||||
try:
|
||||
available = _available()
|
||||
except Exception as exc: # noqa: BLE001 — offline: what is installed still works
|
||||
log.info("model catalogue unavailable: %s", exc)
|
||||
available = []
|
||||
with _lock:
|
||||
return {
|
||||
"installed": _installed(),
|
||||
"installing": {k: dict(v) for k, v in _installing.items()},
|
||||
"available": available,
|
||||
"errors": dict(_errors),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/languages/{code}", status_code=202)
|
||||
def install(code: str) -> dict[str, Any]:
|
||||
if not _CODE.fullmatch(code) or code == PIVOT:
|
||||
raise HTTPException(400, "not a language code")
|
||||
try:
|
||||
_release_files(code, PIVOT)
|
||||
_release_files(PIVOT, code)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return {"started": _start_install(code)}
|
||||
|
||||
|
||||
@app.delete("/languages/{code}")
|
||||
def remove(code: str) -> dict[str, bool]:
|
||||
if code == PIVOT:
|
||||
raise HTTPException(400, "English is always installed")
|
||||
with _lock:
|
||||
_write_installed([c for c in _installed() if c != code])
|
||||
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
||||
_loaded.pop(pair, None)
|
||||
shutil.rmtree(MODELS / pair, ignore_errors=True)
|
||||
return {"removed": True}
|
||||
|
||||
|
||||
@app.post("/detect")
|
||||
def detect(body: DetectIn) -> Any:
|
||||
if isinstance(body.q, str):
|
||||
return [_detect(body.q)]
|
||||
return [_detect(q) for q in body.q]
|
||||
|
||||
|
||||
@app.post("/translate")
|
||||
def translate(body: TranslateIn) -> dict[str, Any]:
|
||||
texts = [body.q] if isinstance(body.q, str) else body.q
|
||||
source = body.source
|
||||
if source == "auto":
|
||||
source = _detect(" ".join(texts))["language"]
|
||||
installed = _installed()
|
||||
for code in (source, body.target):
|
||||
if code not in installed:
|
||||
raise HTTPException(400, f"{code or 'this language'} is not installed")
|
||||
out = _translate(texts, source, body.target)
|
||||
return {"translatedText": out[0] if isinstance(body.q, str) else out, "source": source}
|
||||