This commit is contained in:
Æliott 2026-08-08 20:18:37 -04:00 committed by GitHub
commit b686afab49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 221737 additions and 343 deletions

View file

@ -37,6 +37,7 @@ docker compose up -d --build
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
Language support and catalog maintenance live in the [localization guide](docs/localization.md).
## Features

77
docs/localization.md Normal file
View file

@ -0,0 +1,77 @@
# Localization
Localization is a core Odysseus feature, not an OML plugin.
The language runtime has to run on the login and first-run screens, set the
document language and direction before feature modules render, select the PWA
manifest, and participate in service-worker caching. A plugin loads too late to
own those surfaces reliably. OML can still add translated strings for a plugin's
own UI later, but locale selection and catalog loading belong to core.
## Supported locales
Odysseus follows Steam's **Full Platform Supported Languages** table. The
machine-readable contract and native language names live in
`static/i18n/registry.json`; its `source` field records the Steamworks page used
for the list. Arabic is included even though Valve documents its platform
support as different from the other entries.
Catalogs are loaded on demand. The service worker keeps the core shell precache,
adds the registry and English fallback, then caches a selected locale after its
first request. This avoids loading or precaching every catalog for every user.
English remains active until the user explicitly selects another language.
## Runtime API
`window.odysseusI18n` exposes:
- `ready` and `setLocale(locale)`
- `t(key, parameters)` for semantic catalog keys
- locale-aware number, date, relative-time, list, plural, and collation helpers
New and dynamically-created UI must use semantic keys. A bounded legacy bridge
captures the static application shell before feature modules render and uses
exact-string matching only. Once another module changes a captured node, that
node is permanently removed from legacy translation. Mutation observation is
limited to explicit `data-i18n` elements, so model output and user-authored
messages, notes, documents, email, and session titles are never guessed from
their text. Existing native and styled dialog fallbacks use exact catalog
matching plus bounded placeholder-template matching for captured UI messages;
unknown text is left unchanged.
The static shell, login, setup, signup, validation, authentication, and account
2FA surfaces are wired now. Feature modules that create controls after startup
must add semantic attributes or call `t()` as they are migrated. Catalog
coverage does not grant permission to translate arbitrary late DOM text: doing
that can silently alter session titles, document names, and other user data
that happens to equal an English UI phrase.
## Catalog maintenance
The checked-in source is authoritative. `en.json` and `ledger.json` are
generated snapshots: the ledger records each extracted source location and a
stable hash. Every locale catalog is checked in with the complete key set;
validation fails on missing or extra keys. Present entries must preserve
placeholders, entities, URLs, paths, commands, identifiers, brands, and
technical tokens. Runtime never generates, translates, or fills catalog text.
```bash
node scripts/i18n-catalog.mjs extract
node scripts/i18n-catalog.mjs check-sources
node scripts/i18n-catalog.mjs validate
node scripts/i18n-catalog.mjs manifests
```
Translations are maintained as reviewed locale JSON files, not generated at
runtime. Corrections belong directly in the appropriate catalog; validation
rejects missing or unknown keys, changed protected fragments, HTML injection,
machine marker leaks, unexpected scripts, and hidden Unicode format controls.
Executable strings remain byte-identical to English.
A normal catalog refresh is:
```bash
node scripts/i18n-catalog.mjs extract
node scripts/i18n-catalog.mjs validate
node scripts/i18n-catalog.mjs manifests
```

View file

@ -414,39 +414,60 @@ def _imap_connect(account: str | None = None):
return conn
_FOLDER_ROLE_FLAGS = {
"\\sent": "sent",
"\\trash": "trash",
"\\junk": "junk",
"\\archive": "archive",
"\\all": "all",
"\\drafts": "drafts",
"\\flagged": "flagged",
}
_FOLDER_ROLE_CANDIDATES = {
"inbox": ("INBOX",),
"sent": ("Sent", "[Gmail]/Sent Mail", "[Google Mail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent"),
"trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "Deleted Messages", "Deleted Items"),
"junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"),
"archive": ("Archive", "Archives"),
"all": ("All Mail", "[Gmail]/All Mail", "[Google Mail]/All Mail"),
"drafts": ("Drafts", "Draft", "[Gmail]/Drafts", "[Google Mail]/Drafts"),
"flagged": ("Flagged", "Starred", "[Gmail]/Starred", "[Google Mail]/Starred"),
}
def _parse_list_line(line) -> tuple[str | None, frozenset[str]]:
decoded = line.decode() if isinstance(line, bytes) else str(line)
match = re.match(
r'^\s*\((?P<attrs>[^)]*)\)\s+(?:NIL|"(?:\\.|[^"])*")\s+'
r'(?P<mailbox>"(?:\\.|[^"])*"|\S+)\s*$',
decoded,
re.IGNORECASE,
)
if not match:
return None, frozenset()
mailbox = match.group("mailbox")
if mailbox.startswith('"'):
mailbox = re.sub(r'\\(["\\])', r'\1', mailbox[1:-1])
attrs = frozenset(attr.casefold() for attr in match.group("attrs").split())
return mailbox, attrs
def _detect_sent_folder(conn):
"""Find the account's Sent folder name; fall back to 'Sent'."""
candidates = ("Sent", "[Gmail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent")
try:
status, folders = conn.list()
if status != "OK" or not folders:
return "Sent"
names = []
for f in folders:
decoded = f.decode() if isinstance(f, bytes) else str(f)
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
if m:
names.append(m.group(1) or m.group(2))
for f in folders:
decoded = f.decode() if isinstance(f, bytes) else str(f)
if r"\Sent" in decoded:
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
if m:
return m.group(1) or m.group(2)
for c in candidates:
if c in names:
return c
except Exception:
pass
return "Sent"
return _resolve_folder(conn, "Sent", "sent")
def _folder_name_from_list_line(line) -> str | None:
decoded = line.decode() if isinstance(line, bytes) else str(line)
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
if not m:
return None
return m.group(1) or m.group(2)
return _parse_list_line(line)[0]
def _folder_role_from_flags(line) -> str:
_name, attrs = _parse_list_line(line)
for flag, role in _FOLDER_ROLE_FLAGS.items():
if flag in attrs:
return role
return ""
def _list_folder_lines(conn) -> list:
@ -459,45 +480,36 @@ def _list_folder_lines(conn) -> list:
return []
def _resolve_folder(conn, preferred: str, role: str) -> str:
def _resolve_folder(conn, preferred: str, role: str, folders=None) -> str:
"""Resolve provider-specific folder names like Gmail's [Gmail]/Trash."""
folders = _list_folder_lines(conn)
folders = _list_folder_lines(conn) if folders is None else folders
names = [name for name in (_folder_name_from_list_line(f) for f in folders) if name]
if preferred and preferred in names:
return preferred
role_flags = {
"trash": ("\\Trash",),
"archive": ("\\Archive", "\\All"),
"junk": ("\\Junk",),
}.get(role, ())
for f in folders:
decoded = f.decode() if isinstance(f, bytes) else str(f)
if any(flag in decoded for flag in role_flags):
name = _folder_name_from_list_line(f)
if name:
return name
role_order = (role, "all") if role == "archive" else (role,)
for candidate_role in filter(None, role_order):
for f in folders:
if _folder_role_from_flags(f) == candidate_role:
name = _folder_name_from_list_line(f)
if name:
return name
candidates = {
"trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "Deleted Messages", "Deleted Items"),
"archive": ("Archive", "Archives", "[Gmail]/All Mail", "[Google Mail]/All Mail"),
"junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"),
}.get(role, ())
lower_map = {n.lower(): n for n in names}
for candidate in candidates:
fallback_candidates = _FOLDER_ROLE_CANDIDATES.get(role, ())
if role == "archive":
fallback_candidates += _FOLDER_ROLE_CANDIDATES["all"]
for candidate in fallback_candidates:
if candidate.lower() in lower_map:
return lower_map[candidate.lower()]
return preferred
def _folder_role_from_name(name: str) -> str:
lower = (name or "").lower()
if "trash" in lower or "bin" in lower or "deleted" in lower:
return "trash"
if "junk" in lower or "spam" in lower:
return "junk"
if "archive" in lower or "all mail" in lower:
return "archive"
lower = (name or "").casefold()
for role, candidates in _FOLDER_ROLE_CANDIDATES.items():
if lower in (candidate.casefold() for candidate in candidates):
return role
return ""
@ -979,6 +991,7 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
conn = None
try:
conn = _imap_connect(account)
folder = _resolve_folder(conn, folder, _folder_role_from_name(folder))
select_status, _ = conn.select(_q(folder), readonly=True)
if select_status != "OK":
raise ValueError(f"IMAP folder not found: {folder}")
@ -1101,14 +1114,21 @@ def _search_emails(query, folders=None, max_results=20, account=None):
# IMAP SEARCH OR is binary, so we nest it.
search_cmd = f'(OR OR FROM "{q}" SUBJECT "{q}" TEXT "{q}")'
if folders is None:
folders = ["INBOX", "Sent", "Archive"]
folders = ["INBOX", "Sent", "All Mail", "Archive"]
cache = _get_cached_summaries()
out = []
conn = _imap_connect(account)
touched = []
touched = set()
folder_lines = _list_folder_lines(conn)
try:
for folder in folders:
try:
folder = _resolve_folder(
conn, folder, _folder_role_from_name(folder), folder_lines,
)
if not folder or folder in touched:
continue
touched.add(folder)
status, _ = conn.select(_q(folder), readonly=True)
if status != "OK":
continue

53
package-lock.json generated
View file

@ -5,7 +5,8 @@
"packages": {
"": {
"devDependencies": {
"@antithesishq/bombadil": "^0.6.1"
"@antithesishq/bombadil": "^0.6.1",
"@babel/parser": "^7.29.7"
}
},
"node_modules/@antithesishq/bombadil": {
@ -17,6 +18,56 @@
"bin": {
"bombadil": "bin/bombadil.js"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
}
}
}

View file

@ -4,6 +4,7 @@
"url": "https://github.com/odysseus-dev/odysseus.git"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.6.1"
"@antithesishq/bombadil": "^0.6.1",
"@babel/parser": "^7.29.7"
}
}

View file

@ -378,12 +378,55 @@ def _record_email_received_events(owner: str, account_id: str | None, folder: st
logger.debug("email_received event detection skipped", exc_info=True)
def _folder_name_from_list_line(line) -> str | None:
_FOLDER_ROLE_FLAGS = {
"\\sent": "sent",
"\\trash": "trash",
"\\junk": "junk",
"\\archive": "archive",
"\\all": "all",
"\\drafts": "drafts",
"\\flagged": "flagged",
}
_FOLDER_ROLE_CANDIDATES = {
"inbox": ("INBOX",),
"sent": ("Sent", "[Gmail]/Sent Mail", "[Google Mail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent"),
"trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "[Gmail]/Bin", "Deleted Messages", "Deleted Items"),
"junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"),
"archive": ("Archive", "Archives"),
"all": ("All Mail", "[Gmail]/All Mail", "[Google Mail]/All Mail"),
"drafts": ("Drafts", "Draft", "[Gmail]/Drafts", "[Google Mail]/Drafts"),
"flagged": ("Flagged", "Starred", "[Gmail]/Starred", "[Google Mail]/Starred"),
}
def _parse_list_line(line) -> tuple[str | None, frozenset[str]]:
decoded = line.decode() if isinstance(line, bytes) else str(line)
match = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
match = re.match(
r'^\s*\((?P<attrs>[^)]*)\)\s+(?:NIL|"(?:\\.|[^"])*")\s+'
r'(?P<mailbox>"(?:\\.|[^"])*"|\S+)\s*$',
decoded,
re.IGNORECASE,
)
if not match:
return None
return match.group(1) or match.group(2)
return None, frozenset()
mailbox = match.group("mailbox")
if mailbox.startswith('"'):
mailbox = re.sub(r'\\(["\\])', r'\1', mailbox[1:-1])
attrs = frozenset(attr.casefold() for attr in match.group("attrs").split())
return mailbox, attrs
def _folder_name_from_list_line(line) -> str | None:
return _parse_list_line(line)[0]
def _folder_role_from_flags(line) -> str:
_name, attrs = _parse_list_line(line)
for flag, role in _FOLDER_ROLE_FLAGS.items():
if flag in attrs:
return role
return ""
def _list_imap_folders(conn) -> tuple[list, list[str]]:
@ -397,29 +440,23 @@ def _list_imap_folders(conn) -> tuple[list, list[str]]:
return [], []
def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str:
def _resolve_mail_folder(conn, preferred: str, role: str = "", listing=None) -> str:
"""Resolve provider-specific names such as Gmail's [Gmail]/Bin/Spam."""
folders, names = _list_imap_folders(conn)
folders, names = _list_imap_folders(conn) if listing is None else listing
if preferred and preferred in names:
return preferred
role_flags = {
"trash": ("\\Trash",),
"archive": ("\\Archive", "\\All"),
"junk": ("\\Junk",),
}.get(role, ())
for f in folders:
decoded = f.decode() if isinstance(f, bytes) else str(f)
if any(flag in decoded for flag in role_flags):
name = _folder_name_from_list_line(f)
if name:
return name
candidates = {
"trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "[Gmail]/Bin", "Deleted Messages", "Deleted Items"),
"archive": ("Archive", "Archives", "[Gmail]/All Mail", "[Google Mail]/All Mail", "All Mail"),
"junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"),
}.get(role, ())
role_order = (role, "all") if role == "archive" else (role,)
for candidate_role in filter(None, role_order):
for f in folders:
if _folder_role_from_flags(f) == candidate_role:
name = _folder_name_from_list_line(f)
if name:
return name
lower_map = {n.lower(): n for n in names}
for candidate in candidates:
fallback_candidates = _FOLDER_ROLE_CANDIDATES.get(role, ())
if role == "archive":
fallback_candidates += _FOLDER_ROLE_CANDIDATES["all"]
for candidate in fallback_candidates:
found = lower_map.get(candidate.lower())
if found:
return found
@ -427,13 +464,10 @@ def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str:
def _folder_role_from_name(name: str) -> str:
lower = (name or "").lower()
if "trash" in lower or "bin" in lower or "deleted" in lower:
return "trash"
if "spam" in lower or "junk" in lower:
return "junk"
if "archive" in lower or "all mail" in lower:
return "archive"
lower = (name or "").casefold()
for role, candidates in _FOLDER_ROLE_CANDIDATES.items():
if lower in (candidate.casefold() for candidate in candidates):
return role
return ""
@ -1799,6 +1833,7 @@ def setup_email_routes():
try:
conn, _reused_conn = _pooled_connect(account_id, owner=owner)
conn_ok = True
folder = _resolve_mail_folder(conn, folder, _folder_role_from_name(folder))
select_status, _ = conn.select(_q(folder), readonly=True)
if select_status != "OK":
return {"emails": [], "total": 0, "folder": folder, "error": f"Folder not found: {folder}"}
@ -2750,26 +2785,16 @@ def setup_email_routes():
return indexed_response
with _imap(account_id, owner=owner) as conn:
# If the user asked for INBOX, try to upgrade to All Mail —
# one folder == every email on Gmail-class servers.
effective_folder = folder
folder_listing = _list_imap_folders(conn)
effective_folder = _resolve_mail_folder(
conn, folder, _folder_role_from_name(folder), folder_listing,
)
# If the user asked for INBOX, try to upgrade to the
# locale-independent \All mailbox when the server has one.
if global_search and (folder or "").upper() == "INBOX":
try:
status, folder_lines = conn.list()
if status == "OK" and folder_lines:
for raw in folder_lines:
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
m = re.match(r"\((?P<flags>[^)]*)\)\s+\"[^\"]*\"\s+(?P<name>.+)", raw)
if not m:
continue
flags = (m.group("flags") or "").lower()
name = m.group("name").strip().strip('"')
if "\\all" in flags or "all mail" in name.lower():
effective_folder = name
break
except Exception:
pass
effective_folder = _resolve_mail_folder(
conn, "", "all", folder_listing,
) or effective_folder
conn.select(_q(effective_folder), readonly=True)
search_cmd = _email_imap_search_criteria(q)
@ -3804,7 +3829,11 @@ def setup_email_routes():
):
"""List IMAP folders."""
if _fixture_email_enabled():
return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}}
return {
"folders": ["INBOX", "Archive", "Sent"],
"roles": {"INBOX": "inbox", "Archive": "archive", "Sent": "sent"},
"sync": {"source": "fixture"},
}
cached = _folder_cache_get(account_id, owner)
if cached is not None:
payload = dict(cached)
@ -3822,6 +3851,7 @@ def setup_email_routes():
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"roles": {"INBOX": "inbox", "Sent": "sent", "Archive": "archive"},
"sync": {"source": "folder_cached_only_fallback"},
}
@ -3829,14 +3859,17 @@ def setup_email_routes():
with _imap(account_id, owner=owner) as conn:
status, folders = conn.list()
result = []
roles = {}
for f in folders or []:
decoded = f.decode() if isinstance(f, bytes) else f
match = re.search(r'"([^"]*)"$|(\S+)$', decoded)
if match:
name = match.group(1) or match.group(2)
name = _folder_name_from_list_line(f)
if name:
result.append(name)
role = _folder_role_from_flags(f) or _folder_role_from_name(name)
if role:
roles[name] = role
return {
"folders": result,
"roles": roles,
"sync": {
"source": "imap",
"updated_at": datetime.utcnow().isoformat() + "Z",
@ -3860,12 +3893,13 @@ def setup_email_routes():
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"roles": {"INBOX": "inbox", "Sent": "sent", "Archive": "archive"},
"error": "Folder list timed out",
"sync": {"source": "folder_timeout_fallback"},
}
except Exception as e:
logger.error(f"list_folders failed: {e}")
return {"folders": [], "error": "Mail operation failed"}
return {"folders": [], "roles": {}, "error": "Mail operation failed"}
@router.post("/mark-answered/{uid}")
async def mark_answered(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):

936
scripts/i18n-catalog.mjs Normal file
View file

@ -0,0 +1,936 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import babelParser from '@babel/parser';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const I18N_DIR = path.join(ROOT, 'static', 'i18n');
const STEAM_LANGUAGE_SOURCE = 'https://partner.steamgames.com/doc/store/localization/languages';
const { parse: parseJavaScript } = babelParser;
const HTML_FILES = ['static/index.html', 'static/login.html'];
const JSON_UI_FILES = ['static/manifest.json'];
const PYTHON_ROOTS = ['app.py', 'routes', 'companion', 'src', 'services'];
const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.tsx', '.jsx']);
const JS_EXCLUDES = [
/\/static\/lib\//,
/\.min\.js$/,
/\/static\/js\/i18n\.js$/,
/\/static\/js\/modelCatalog\.js$/,
/\/static\/js\/mimoModels\.js$/,
/\/static\/js\/mimoProviders\.generated\.js$/,
/\/node_modules\//,
];
const LOCALES = Object.freeze({
ar: { name: 'العربية', dir: 'rtl' },
bg: { name: 'български език', dir: 'ltr' },
'zh-CN': { name: '简体中文', dir: 'ltr' },
'zh-TW': { name: '繁體中文', dir: 'ltr' },
cs: { name: 'Čeština', dir: 'ltr' },
da: { name: 'Dansk', dir: 'ltr' },
nl: { name: 'Nederlands', dir: 'ltr' },
en: { name: 'English', dir: 'ltr' },
fi: { name: 'Suomi', dir: 'ltr' },
fr: { name: 'Français', dir: 'ltr' },
de: { name: 'Deutsch', dir: 'ltr' },
el: { name: 'Ελληνικά', dir: 'ltr' },
hu: { name: 'Magyar', dir: 'ltr' },
id: { name: 'Bahasa Indonesia', dir: 'ltr' },
it: { name: 'Italiano', dir: 'ltr' },
ja: { name: '日本語', dir: 'ltr' },
ko: { name: '한국어', dir: 'ltr' },
ms: { name: 'Bahasa Melayu', dir: 'ltr' },
no: { name: 'Norsk', dir: 'ltr' },
pl: { name: 'Polski', dir: 'ltr' },
pt: { name: 'Português', dir: 'ltr' },
'pt-BR': { name: 'Português-Brasil', dir: 'ltr' },
ro: { name: 'Română', dir: 'ltr' },
ru: { name: 'Русский', dir: 'ltr' },
es: { name: 'Español-España', dir: 'ltr' },
'es-419': { name: 'Español-Latinoamérica', dir: 'ltr' },
sv: { name: 'Svenska', dir: 'ltr' },
th: { name: 'ไทย', dir: 'ltr' },
tr: { name: 'Türkçe', dir: 'ltr' },
uk: { name: 'Українська', dir: 'ltr' },
vi: { name: 'Tiếng Việt', dir: 'ltr' },
});
const ALIASES = Object.freeze({
zh: 'zh-CN',
'zh-Hans': 'zh-CN',
'zh-SG': 'zh-CN',
'zh-Hant': 'zh-TW',
'zh-HK': 'zh-TW',
'zh-MO': 'zh-TW',
'pt-PT': 'pt',
nb: 'no',
nn: 'no',
'nb-NO': 'no',
'nn-NO': 'no',
in: 'id',
'es-ES': 'es',
'es-AR': 'es-419',
'es-BO': 'es-419',
'es-CL': 'es-419',
'es-CO': 'es-419',
'es-CR': 'es-419',
'es-CU': 'es-419',
'es-DO': 'es-419',
'es-EC': 'es-419',
'es-GT': 'es-419',
'es-HN': 'es-419',
'es-MX': 'es-419',
'es-NI': 'es-419',
'es-PA': 'es-419',
'es-PE': 'es-419',
'es-PR': 'es-419',
'es-PY': 'es-419',
'es-SV': 'es-419',
'es-US': 'es-419',
'es-UY': 'es-419',
'es-VE': 'es-419',
});
// Semantic keys for UI that is created after page load. Source extraction
// cannot safely infer ownership for these values, so keep the small explicit
// contract next to the catalog tooling.
const CORE_MESSAGES = Object.freeze({
'auth.first_time_setup': 'First-time setup — create your admin account',
'auth.create_admin_account': 'Create Admin Account',
'auth.create_account': 'Create Account',
'auth.already_have_account': 'Already have an account?',
'auth.passwords_do_not_match': 'Passwords do not match',
'auth.password_minimum': 'Password must be at least {0} characters',
'auth.username_reserved': 'This username is reserved',
'auth.invalid_code': 'Invalid code',
'auth.login_failed': 'Login failed',
'auth.account_creation_failed': 'Account creation failed',
'auth.two_factor_code': '2FA Code',
'auth.two_factor_placeholder': 'Enter 6-digit code',
'auth.two_factor_aria': 'Two-factor authentication code',
'auth.verify': 'Verify',
'auth.hide_password': 'Hide password',
'auth.too_many_requests': 'Too many requests — try again later',
'auth.already_configured': 'Already configured',
'auth.username_required': 'Username is required',
'auth.setup_failed': 'Setup failed',
'auth.run_setup_first': 'Run setup first',
'auth.registration_disabled': 'Registration is disabled. Ask an admin for an account.',
'auth.username_taken': 'Username already taken',
'auth.invalid_credentials': 'Invalid credentials',
'auth.invalid_two_factor_code': 'Invalid 2FA code',
'ui.email.folder.inbox': 'INBOX',
'ui.email.folder.sent': 'Sent',
'ui.email.folder.flagged': 'Starred',
'ui.email.folder.archive': 'Archive',
'ui.email.folder.all': 'All Mail',
'ui.email.folder.junk': 'Junk',
'ui.email.folder.trash': 'Trash',
'ui.email.folder.drafts': 'Drafts',
'ui.email.folder.scheduled': 'Scheduled',
'css.copied': '✓ Copied',
'css.editing': 'EDITING',
'css.drop_to_attach': 'Drop to attach',
'css.write_email': 'Write your email…',
'css.planning_goal': 'AI is planning your goal…',
'css.no_title': 'No title',
'ui.language_changed': 'Language changed.',
});
const BRANDS = Object.freeze([
'Odysseus',
'OpenAI',
'ChatGPT',
'Codex',
'Anthropic',
'Claude',
'Google',
'Gemini',
'GitHub',
'Gmail',
'Microsoft',
'Outlook',
'Matrix',
'Discord',
'Slack',
'Notion',
'Box',
'Figma',
'Atlassian',
'Rovo',
'SharePoint',
'Teams',
'Obsidian',
'Hugging Face',
'Ollama',
'SearXNG',
'DuckDuckGo',
'Brave',
'Playwright',
'Chromium',
'llama.cpp',
'Perplexity',
'Copilot',
'Groq',
'Tavily',
'Mistral',
'DeepSeek-V4-Flash',
'DeepSeek-V4',
'DeepSeek',
'Qwen3.5',
'Qwen',
'OpenRouter',
'PyTorch',
'xAI',
]);
const STABLE_TOKENS = Object.freeze([
'AI',
'API',
'JSON',
'HTML',
'CSS',
'JavaScript',
'TypeScript',
'Python',
'Rust',
'OAuth',
'MCP',
'URL',
'HTTP',
'HTTPS',
'PDF',
'PWA',
'TOTP',
'CalDAV',
'IMAP',
'SMTP',
'WebSocket',
'SSE',
'SQL',
'Markdown',
'CSV',
'ZIP',
'safetensors',
'vLLM',
'SGLang',
'CUDA',
'ROCm',
'GGUF',
'skills.sh',
'MLX',
'NCCL',
'FlashInfer',
'Triton',
'tmux',
'mmproj',
'CardDAV',
'torch',
'rembg',
'llama-cpp-python',
'hf_...',
]);
const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}/g;
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
const FORMAT_CONTROL = /\p{Cf}/u;
const HTML_TAG = /<\/?[a-z][^>]*>/iu;
const MACHINE_MARKER = /ZXQ|QXZ|ZXXZ|ZXZ|QLOCK/iu;
const SCRIPT_PATTERNS = Object.freeze({
Arabic: /\p{Script=Arabic}/u,
Cyrillic: /\p{Script=Cyrillic}/u,
Greek: /\p{Script=Greek}/u,
Han: /\p{Script=Han}/u,
Hiragana: /\p{Script=Hiragana}/u,
Katakana: /\p{Script=Katakana}/u,
Hangul: /\p{Script=Hangul}/u,
Thai: /\p{Script=Thai}/u,
});
const LOCALE_SCRIPTS = Object.freeze({
ar: new Set(['Arabic']),
bg: new Set(['Cyrillic']),
el: new Set(['Greek']),
ja: new Set(['Han', 'Hiragana', 'Katakana']),
ko: new Set(['Han', 'Hangul']),
ru: new Set(['Cyrillic']),
th: new Set(['Thai']),
uk: new Set(['Cyrillic']),
'zh-CN': new Set(['Han']),
'zh-TW': new Set(['Han']),
});
const EXACT_TOKEN_PATTERNS = [
/\{(?:[A-Za-z_][A-Za-z0-9_]*|\d+)\}/gu,
/\{[A-Za-z_][A-Za-z0-9_]*![rsa]\}/gu,
/&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]+);/giu,
/\{\{[\s\S]*?\}\}/gu,
];
const SOURCE_LITERAL_PATTERNS = [
/\b(?:https?|wss?):\/\/[^\s<>"')\]]+/giu,
/(?<![:/\w])(?:~\/|\/)(?:[A-Za-z0-9_.@+-]+\/)*[A-Za-z0-9_.@+-]+/gu,
/(?<![\w-])--[A-Za-z][\w-]*/gu,
/\b(?:localhost|(?:[a-z0-9-]+\.)+[a-z]{2,})(?::\d+)?(?:\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]*)?/giu,
/\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b/gu,
/\b[a-z]+(?:[A-Z][A-Za-z0-9]*)+\b/g,
/\{(?:(?:\s*"(?:[^"\\]|\\.)*"\s*:\s*"(?:[^"\\]|\\.)*"\s*,?)+)\s*\}/gu,
/"(?:pip|python3?|curl|docker|sudo|uv|export|--)[^"]*"/gu,
/\bpip3?\s+install\s+"[^"\r\n]+"/gu,
/\bpip3?\s+install\b/gu,
/\bcurl\b[^\r\n]*?\|\s*sh\b/gu,
/\bcurl\b/gu,
/\|\s*sh\b/gu,
/\b[A-Za-z0-9._-]+\[[A-Za-z0-9._-]+\]/gu,
/\blist\/search\/view\/add\/update\/delete\/toggle_item\b/gu,
/`[^`\r\n]+`/gu,
/\{[a-z_]+(?:,\s*[a-z_]+)+\}/gu,
/\{[a-z_]+ or '[^']*'\}/gu,
];
const CSS_LITERAL = /^(?:style\s*=\s*["'])?(?:(?:--[a-z0-9_-]+|-?(?:webkit|moz)-[a-z-]+|(?:align|animation|aspect|backdrop|background|border|bottom|box|clip|color|cursor|display|filter|flex|float|font|gap|grid|height|inset|justify|left|line|margin|max|min|object|opacity|outline|overflow|padding|pointer|position|right|text|top|touch|transform|transition|user|vertical|visibility|white|width|word|z-index)[a-z-]*)\s*:[^;]*)(?:;\s*(?:--[a-z0-9_-]+|-?[a-z][a-z0-9-]*)\s*:[^;]*)*;?(?:["'])?$/iu;
const FONT_FACE_LITERAL = /^@font-face\s*\{[\s\S]*\}$/iu;
const SELECTOR_LITERAL = /^(?:[#.][A-Za-z_-][\s\S]*|\[(?:data-|id[$^*|~]?=)[\s\S]*|(?:select|div)[.#\[][\s\S]*)$/u;
const SHELL_OR_CONFIG_LITERAL = /^(?:[A-Z_][A-Z0-9_]*\s*=|(?:capture-pane|curl|docker|du|has-session|kill-session|pip|pkill|python3?|uv|powershell)\s|tmux\s+kill-session\s|Remove-Item\s|sudo\s|export\s+(?:[A-Z_]|\{)|--[a-z]|-[a-z]$|\{(?:[A-Za-z_][A-Za-z0-9_]*|\d+)\}\s+-m\s|import\s+|(?:bash|npx|python3?)$)/u;
const CLASS_LIST_LITERAL = /^(?=[a-z0-9_-]*(?:-|_))[a-z][a-z0-9_-]*(?: [a-z][a-z0-9_-]*)+$/u;
const JINJA_BLOCK = /\{%[\s\S]*?%\}/u;
const MUSTACHE = /\{\{[\s\S]*?\}\}/gu;
const SLASH_COMMAND = /^\/[a-z][a-z0-9-]*(?:\s+(?:\[[^\]]+\]|<[^>]+>))*$/iu;
const HTML_ENTITIES = Object.freeze({
amp: '&',
darr: '↓',
ge: '≥',
gt: '>',
larr: '←',
lsaquo: '',
lt: '<',
mdash: '—',
middot: '·',
minus: '',
nbsp: '\u00a0',
quot: '"',
rarr: '→',
rsaquo: '',
times: '×',
uarr: '↑',
});
function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function walk(target) {
if (!fs.existsSync(target)) return [];
const stat = fs.statSync(target);
if (stat.isFile()) return [target];
return fs.readdirSync(target, { withFileTypes: true })
.flatMap(entry => walk(path.join(target, entry.name)));
}
function relative(file) {
return path.relative(ROOT, file).split(path.sep).join('/');
}
function normalizeSource(raw) {
return String(raw ?? '')
.replace(/\r\n?/g, '\n')
.replace(/[\t\n ]+/g, ' ')
.trim();
}
function decodeHtmlEntities(raw) {
return String(raw).replace(
/&(?:#(\d+)|#x([0-9a-f]+)|([a-z][a-z0-9]+));/giu,
(entity, decimal, hexadecimal, named) => {
if (decimal) return String.fromCodePoint(Number(decimal));
if (hexadecimal) return String.fromCodePoint(Number.parseInt(hexadecimal, 16));
return HTML_ENTITIES[named.toLowerCase()] ?? entity;
},
);
}
function looksUserFacing(raw) {
const value = normalizeSource(decodeHtmlEntities(raw));
if (value.length < 2 || value.length > 600 || !/[A-Za-z]/.test(value)) return false;
if (isCodeLiteral(value) || /<\/?[a-z][^>]*>/iu.test(value)) return false;
if (/^\{[^{}]+\}$/u.test(value) || /\{[^{}]*(?:[().]|::)[^{}]*\}/u.test(value)) return false;
if (/^(?:\\[0-9a-f]{2,6})$/iu.test(value)) return false;
if (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/u.test(value)) return false;
if (/^[A-Za-z]+Error$/u.test(value)) return false;
if (/^\d+(?:\.\d+)?x$/iu.test(value)) return false;
if (/^(?:-?\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%|ms|s)?(?:\s|$)|rgba?\(|hsla?\(|color-mix\(|var\(--|calc\()/iu.test(value)) return false;
if (/\b(?:rgba?|hsla?|color-mix|linear-gradient|radial-gradient|box-shadow|translate[XY]?|scale[XY]?|rotate)\s*\(/iu.test(value)) return false;
if (/^(?:https?:|data:|blob:|mailto:|tel:|\/api\/|\/static\/|\.\/|\.\.\/)/iu.test(value)) return false;
if (/^(?:#[\w-]+|\.[\w-]+|--[\w-]+|[\w-]+\.(?:js|css|py|rs|ts|tsx|json|md|html|svg|png|jpe?g|gif|webp|woff2?))$/iu.test(value)) return false;
if (/^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9_-]+)+$/u.test(value)) return false;
if (/^[a-z][a-zA-Z0-9_]*$/u.test(value) && /[A-Z_]/u.test(value)) return false;
if (/^[A-Za-z_$][\w$-]*$/u.test(value) && /[_$]|[a-z][A-Z]/u.test(value)) return false;
if (/^[\w-]+\/[\w./-]+$/u.test(value)) return false;
if (/^[\w.-]+@[\w.-]+$/u.test(value)) return false;
if (/^[{}[\]().,:;!?+*=|&%$#@~`"'\\/-]+$/u.test(value)) return false;
const symbols = (value.match(/[{}[\]<>_=\\/]/gu) || []).length;
if (symbols > Math.max(6, value.length / 5)) return false;
if (/\\[bBdDsSwW]/u.test(value) && /[*+?{}[\]()]/u.test(value)) return false;
if (/^(?:GET|POST|PUT|PATCH|DELETE) \/\S+/u.test(value)) return false;
return true;
}
function slugFor(source) {
const withoutPlaceholders = source.replace(PLACEHOLDER, ' value ');
const slug = withoutPlaceholders
.normalize('NFKD')
.replace(/[\u0300-\u036f]/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/gu, '.')
.replace(/^\.+|\.+$/gu, '')
.split('.')
.filter(Boolean)
.slice(0, 10)
.join('.')
.slice(0, 72);
return `ui.${slug || 'message'}`;
}
function hashText(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function placeholders(value) {
return [...String(value).matchAll(PLACEHOLDER)].map(match => match[1]).sort();
}
function tokenCount(value, token) {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boundary = /^[A-Za-z0-9]+$/.test(token)
? `(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`
: escaped;
return [...String(value).matchAll(new RegExp(boundary, 'gu'))].length;
}
function tokens(value, pattern) {
pattern.lastIndex = 0;
return [...String(value).matchAll(pattern)]
.map(match => match[0].replace(/[.,;:!?]+$/u, ''))
.sort();
}
function sameTokenMultiset(source, target, pattern) {
return JSON.stringify(tokens(source, pattern)) === JSON.stringify(tokens(target, pattern));
}
function preservesSourceTokens(source, target, pattern) {
const counts = values => values.reduce((result, token) => {
result.set(token, (result.get(token) || 0) + 1);
return result;
}, new Map());
const expected = counts(tokens(source, pattern));
const actual = counts(tokens(target, pattern));
return [...expected].every(([token, count]) => actual.get(token) === count);
}
function isCodeLiteral(source) {
const value = String(source ?? '').trim();
if (!value) return false;
const expressions = [...value.matchAll(MUSTACHE)];
const rawTemplate = JINJA_BLOCK.test(value)
|| (expressions.length && /^(?:model|user)?$/u.test(value.replace(MUSTACHE, '').trim()));
let structuredJson = false;
try {
const parsed = JSON.parse(value);
structuredJson = parsed !== null && typeof parsed === 'object';
} catch {
// Natural text can contain braces; only valid JSON is opaque.
}
return value === 'ms)'
|| CSS_LITERAL.test(value)
|| FONT_FACE_LITERAL.test(value)
|| SELECTOR_LITERAL.test(value)
|| SHELL_OR_CONFIG_LITERAL.test(value)
|| CLASS_LIST_LITERAL.test(value)
|| SLASH_COMMAND.test(value)
|| structuredJson
|| rawTemplate;
}
function structurallyValid(source, target) {
if (typeof target !== 'string' || !target.trim()) return false;
if (isCodeLiteral(source)) return target === source;
if (
BIDI_CONTROLS.test(target)
|| FORMAT_CONTROL.test(target)
|| HTML_TAG.test(target)
|| MACHINE_MARKER.test(target)
) return false;
if (JSON.stringify(placeholders(source)) !== JSON.stringify(placeholders(target))) return false;
if (!EXACT_TOKEN_PATTERNS.every(pattern => sameTokenMultiset(source, target, pattern))) return false;
if (!SOURCE_LITERAL_PATTERNS.every(pattern => preservesSourceTokens(source, target, pattern))) return false;
return [...BRANDS, ...STABLE_TOKENS].every(
token => tokenCount(source, token) === tokenCount(target, token),
);
}
function makeCollector(existingEnglish = {}) {
const bySource = new Map();
const byKey = new Map(Object.entries(existingEnglish));
for (const [key, source] of Object.entries(existingEnglish)) {
bySource.set(source, key);
bySource.set(decodeHtmlEntities(source), key);
}
const entries = new Map();
function keyFor(source) {
if (bySource.has(source)) return bySource.get(source);
const base = slugFor(source);
if (!byKey.has(base) || byKey.get(base) === source) {
byKey.set(base, source);
bySource.set(source, base);
return base;
}
const suffix = crypto.createHash('sha1').update(source).digest('hex').slice(0, 8);
const key = `${base}.${suffix}`;
byKey.set(key, source);
bySource.set(source, key);
return key;
}
function add(raw, file, line, kind = 'literal') {
const source = normalizeSource(decodeHtmlEntities(raw));
if (!looksUserFacing(source)) return;
const key = keyFor(source);
const record = entries.get(key) || { key, source, kind, locations: [] };
const location = `${relative(file)}:${line || 1}`;
if (!record.locations.includes(location)) record.locations.push(location);
if (record.kind !== kind) record.kind = 'mixed';
entries.set(key, record);
}
return { add, entries };
}
function maskHtmlBlock(raw) {
const newlines = String(raw).match(/\n/gu)?.join('') || '';
return `<i18n-skip></i18n-skip>${newlines}`;
}
function extractHtmlText(raw, file, baseLine, collector, kind = 'html') {
const withoutComments = raw
.replace(/<!--[\s\S]*?-->/gu, maskHtmlBlock)
.replace(/<script\b[\s\S]*?<\/script>/giu, maskHtmlBlock)
.replace(/<style\b[\s\S]*?<\/style>/giu, maskHtmlBlock)
.replace(/<(?:code|pre)\b[\s\S]*?<\/(?:code|pre)>/giu, maskHtmlBlock);
const attrPattern = /\b(?:placeholder|title|aria-label|aria-description|alt)\s*=\s*(["'])([\s\S]*?)\1/giu;
let match;
while ((match = attrPattern.exec(withoutComments))) {
const line = baseLine + withoutComments.slice(0, match.index).split('\n').length - 1;
collector.add(match[2], file, line, `${kind}-attribute`);
}
const textPattern = />([^<>]+)</gu;
while ((match = textPattern.exec(withoutComments))) {
const line = baseLine + withoutComments.slice(0, match.index).split('\n').length - 1;
collector.add(match[1], file, line, `${kind}-text`);
}
}
function templateSource(node) {
let value = '';
node.quasis.forEach((quasi, index) => {
value += quasi.value.cooked ?? quasi.value.raw;
if (index < node.expressions.length) value += `{${index}}`;
});
return value;
}
function memberName(node) {
if (!node) return '';
if (node.type === 'Identifier') return node.name;
if (node.type === 'StringLiteral') return node.value;
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
return `${memberName(node.object)}.${memberName(node.property)}`;
}
return '';
}
const UI_PROPERTIES = new Set([
'text', 'textContent', 'innerText', 'innerHTML', 'label', 'title', 'tooltip',
'placeholder', 'description', 'message', 'help', 'hint', 'caption', 'heading',
'aria-label', 'ariaLabel', 'aria-description', 'ariaDescription', 'alt',
'emptyText', 'errorText', 'loadingText', 'confirmText', 'cancelText',
]);
const UI_CALLS = /(?:^|\.)(?:h|createElement|setAttribute|showToast|toast|notify|alert|confirm|prompt|showError|showMessage|setStatus|setMessage|setText|openConfirm|openPrompt|styledConfirm|styledPrompt|renderMenu|showChooser|showCommands|addOption|addItem)$/iu;
const UI_CONTAINER_NAMES = /^(?:.*(?:label|title|text|message|description|tooltip|placeholder|help|hint|caption|heading|tabs?|options?|actions?|commands?|menus?|statuses|errors?|empty|loading|confirm|cancel).*)$/iu;
function isUiContext(node, ancestors) {
let current = node;
for (let index = ancestors.length - 1, depth = 0; index >= 0 && depth < 6; index -= 1, depth += 1) {
const parent = ancestors[index];
if ((parent.type === 'ObjectProperty' || parent.type === 'ObjectMethod') && parent.value === current) {
const key = memberName(parent.key);
if (UI_PROPERTIES.has(key) || UI_CONTAINER_NAMES.test(key)) return true;
}
if (parent.type === 'JSXAttribute') {
const name = memberName(parent.name);
if (UI_PROPERTIES.has(name)) return true;
}
if (parent.type === 'AssignmentExpression' && parent.right === current) {
const leaf = memberName(parent.left).split('.').at(-1);
if (UI_PROPERTIES.has(leaf) || UI_CONTAINER_NAMES.test(leaf)) return true;
}
if (parent.type === 'CallExpression') {
const name = memberName(parent.callee);
if (UI_CALLS.test(name) || UI_CONTAINER_NAMES.test(name.split('.').at(-1))) return true;
}
if (parent.type === 'VariableDeclarator') {
const name = memberName(parent.id);
if (UI_CONTAINER_NAMES.test(name)) return true;
}
current = parent;
}
return false;
}
function isLikelyStandaloneMessage(value) {
if (value.length < 4) return false;
return /^(?:Loading|Saving|Saved|Failed|Unable|Error|Warning|Delete|Remove|Add|Create|Edit|Open|Close|Cancel|Confirm|Search|Select|Choose|No |Show|Hide|Enable|Disable|Copy|Copied|Download|Upload|Export|Import|Refresh|Retry|Start|Stop|Run|Running|Ready|Connected|Disconnected|Unknown)\b/iu.test(value);
}
function skipStringNode(node, parent) {
if (!parent) return false;
if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(parent.type)) return true;
if ((parent.type === 'ObjectProperty' || parent.type === 'ObjectMethod') && parent.key === node && !parent.computed) return true;
if ((parent.type === 'MemberExpression' || parent.type === 'OptionalMemberExpression') && parent.property === node && !parent.computed) return true;
if (parent.type === 'Directive' || parent.type === 'DirectiveLiteral') return true;
return parent.type === 'CallExpression' && parent.callee?.type === 'Import';
}
function isConsoleContext(ancestors) {
return ancestors.some(parent => (
parent.type === 'CallExpression'
&& /^console\./u.test(memberName(parent.callee))
));
}
function visitAst(node, ancestors, callback) {
if (!node || typeof node !== 'object') return;
if (typeof node.type === 'string') callback(node, ancestors);
const nextAncestors = typeof node.type === 'string' ? [...ancestors, node] : ancestors;
for (const [key, value] of Object.entries(node)) {
if (['loc', 'start', 'end', 'extra', 'errors', 'comments', 'tokens'].includes(key)) continue;
if (Array.isArray(value)) value.forEach(child => visitAst(child, nextAncestors, callback));
else if (value && typeof value === 'object') visitAst(value, nextAncestors, callback);
}
}
function extractJavaScript(file, collector) {
const code = fs.readFileSync(file, 'utf8');
let ast;
try {
ast = parseJavaScript(code, {
sourceType: 'unambiguous',
allowAwaitOutsideFunction: true,
allowReturnOutsideFunction: true,
errorRecovery: true,
plugins: [
'jsx', 'typescript', 'decorators-legacy', 'classProperties',
'classPrivateProperties', 'classPrivateMethods', 'dynamicImport',
'importMeta', 'topLevelAwait',
],
});
} catch (error) {
process.stderr.write(`parse warning: ${relative(file)}: ${error.message}\n`);
return;
}
visitAst(ast, [], (node, ancestors) => {
const parent = ancestors.at(-1);
if (node.type === 'StringLiteral') {
if (skipStringNode(node, parent) || isConsoleContext(ancestors)) return;
const value = node.value;
const line = node.loc?.start.line || 1;
if (/<[a-z][\s\S]*>/iu.test(value)) {
extractHtmlText(value, file, line, collector, 'js-string-html');
} else if (isUiContext(node, ancestors) || isLikelyStandaloneMessage(normalizeSource(value))) {
collector.add(value, file, line, 'js-string');
}
} else if (node.type === 'TemplateLiteral') {
if (parent?.type === 'TaggedTemplateExpression' || isConsoleContext(ancestors)) return;
const source = templateSource(node);
const line = node.loc?.start.line || 1;
if (/<[a-z][\s\S]*>/iu.test(source)) {
extractHtmlText(source, file, line, collector, 'js-template-html');
} else if (isUiContext(node, ancestors) || isLikelyStandaloneMessage(normalizeSource(source))) {
collector.add(source, file, line, 'js-template');
}
} else if (node.type === 'JSXText') {
collector.add(node.value, file, node.loc?.start.line, 'jsx-text');
}
});
}
function extractQuotedSource(file, collector) {
const code = fs.readFileSync(file, 'utf8');
const triplePattern = /"""([\s\S]*?)"""|'''([\s\S]*?)'''/gu;
let match;
const htmlRanges = [];
while ((match = triplePattern.exec(code))) {
const raw = match[1] ?? match[2] ?? '';
if (/<(?:html|body|main|div|form|h1|h2|p|button|label|input)\b/iu.test(raw)) {
extractHtmlText(raw, file, code.slice(0, match.index).split('\n').length, collector, 'server-html');
}
htmlRanges.push([match.index, triplePattern.lastIndex]);
}
const lines = code.split('\n');
const context = /(?:HTTPException|JSONResponse|HTMLResponse|detail\s*=|["'](?:error|message|detail|status|title|description|label)["']\s*:|raise\s+(?:ValueError|RuntimeError)|return\s+\{)/u;
const quoted = /(["'])((?:\\.|(?!\1).){2,600})\1/gu;
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const inTriple = htmlRanges.some(([start, end]) => offset >= start && offset < end);
offset += line.length + 1;
if (inTriple || !context.test(line)) continue;
while ((match = quoted.exec(line))) {
const decoded = match[2].replace(/\\n/gu, ' ').replace(/\\t/gu, ' ').replace(/\\(["'])/gu, '$1');
if (isLikelyStandaloneMessage(normalizeSource(decoded))) collector.add(decoded, file, index + 1, 'server-string');
}
quoted.lastIndex = 0;
}
}
function extractCss(file, collector) {
const code = fs.readFileSync(file, 'utf8');
const pattern = /\bcontent\s*:\s*(["'])(.*?)\1/gu;
let match;
while ((match = pattern.exec(code))) {
collector.add(match[2], file, code.slice(0, match.index).split('\n').length, 'css-content');
}
}
function sourceFiles() {
return walk(path.join(ROOT, 'static'))
.filter(file => SOURCE_EXTENSIONS.has(path.extname(file)))
.filter(file => !JS_EXCLUDES.some(pattern => pattern.test(file)))
.sort();
}
function buildSourceSnapshot() {
const englishFile = path.join(I18N_DIR, 'en.json');
const collector = makeCollector(readJson(englishFile, {}));
for (const name of HTML_FILES) {
const file = path.join(ROOT, name);
if (fs.existsSync(file)) extractHtmlText(fs.readFileSync(file, 'utf8'), file, 1, collector);
}
for (const name of JSON_UI_FILES) {
const file = path.join(ROOT, name);
const data = readJson(file, {});
for (const key of ['name', 'short_name', 'description']) collector.add(data[key], file, 1, 'json-metadata');
}
for (const file of sourceFiles()) extractJavaScript(file, collector);
for (const root of PYTHON_ROOTS) {
for (const file of walk(path.join(ROOT, root))) {
if (file.endsWith('.py')) extractQuotedSource(file, collector);
}
}
extractCss(path.join(ROOT, 'static', 'style.css'), collector);
for (const [key, source] of Object.entries(CORE_MESSAGES)) {
collector.entries.set(key, {
key,
source,
kind: 'semantic',
locations: ['scripts/i18n-catalog.mjs:CORE_MESSAGES'],
});
}
const records = [...collector.entries.values()]
.map(record => ({ ...record, locations: record.locations.sort() }))
.sort((left, right) => left.key.localeCompare(right.key));
const english = Object.fromEntries(records.map(record => [record.key, record.source]));
return {
english,
ledger: {
version: 1,
source_count: records.length,
source_hash: hashText(JSON.stringify(english)),
roots: {
html: HTML_FILES,
javascript: sourceFiles().map(relative),
server: PYTHON_ROOTS,
css: ['static/style.css'],
},
entries: records,
},
};
}
function extractCatalog({ check = false } = {}) {
const snapshot = buildSourceSnapshot();
if (check) {
const ledger = readJson(path.join(I18N_DIR, 'ledger.json'), {});
const committedEnglish = readJson(path.join(I18N_DIR, 'en.json'), {});
if (
ledger.source_hash !== snapshot.ledger.source_hash
|| ledger.source_count !== snapshot.ledger.source_count
|| JSON.stringify(ledger.entries) !== JSON.stringify(snapshot.ledger.entries)
|| JSON.stringify(committedEnglish) !== JSON.stringify(snapshot.english)
) {
throw new Error(
`catalog source snapshot is stale: expected ${snapshot.ledger.source_count}/${snapshot.ledger.source_hash}, `
+ `found ${ledger.source_count || 0}/${ledger.source_hash || 'missing'}; run extract`,
);
}
process.stdout.write(`source snapshot current keys=${snapshot.ledger.source_count} hash=${snapshot.ledger.source_hash}\n`);
return;
}
writeJson(path.join(I18N_DIR, 'en.json'), snapshot.english);
for (const locale of Object.keys(LOCALES).filter(id => id !== 'en')) {
const file = path.join(I18N_DIR, `${locale}.json`);
const values = readJson(file, {});
writeJson(file, Object.fromEntries(
Object.keys(snapshot.english)
.filter(key => typeof values[key] === 'string')
.map(key => [key, values[key]]),
));
}
writeJson(path.join(I18N_DIR, 'ledger.json'), snapshot.ledger);
writeJson(path.join(I18N_DIR, 'registry.json'), registry());
writeJson(path.join(I18N_DIR, 'brands.json'), {
brands: BRANDS,
stable_tokens: STABLE_TOKENS,
});
process.stdout.write(`extracted=${snapshot.ledger.source_count} hash=${snapshot.ledger.source_hash}\n`);
}
function registry() {
return {
version: 1,
source: STEAM_LANGUAGE_SOURCE,
support_level: 'full-platform',
default_locale: 'en',
locales: Object.fromEntries(
Object.entries(LOCALES).map(([id, meta]) => [id, { name: meta.name, dir: meta.dir }]),
),
aliases: ALIASES,
};
}
function unexpectedScripts(locale, source, target) {
const allowed = LOCALE_SCRIPTS[locale] || new Set();
const unexpected = [];
for (const [script, pattern] of Object.entries(SCRIPT_PATTERNS)) {
if (allowed.has(script)) continue;
if ([...target].some(character => pattern.test(character) && !source.includes(character))) {
unexpected.push(script);
}
}
return unexpected;
}
function validate() {
extractCatalog({ check: true });
const english = readJson(path.join(I18N_DIR, 'en.json'));
if (!english || Array.isArray(english)) throw new Error('missing or invalid en.json');
const expectedKeys = Object.keys(english).sort();
const errors = [];
const warnings = [];
const actualRegistry = readJson(path.join(I18N_DIR, 'registry.json'));
if (JSON.stringify(actualRegistry) !== JSON.stringify(registry())) {
errors.push('registry.json does not match the Steam full-platform locale contract');
}
for (const [locale, meta] of Object.entries(LOCALES)) {
const values = readJson(path.join(I18N_DIR, `${locale}.json`));
if (!values || Array.isArray(values)) {
errors.push(`${locale}: missing or invalid catalog`);
continue;
}
const keys = Object.keys(values).sort();
const missing = expectedKeys.filter(key => !(key in values));
const extra = keys.filter(key => !(key in english));
if (missing.length) errors.push(`${locale}: ${missing.length} missing keys`);
if (extra.length) errors.push(`${locale}: ${extra.length} extra keys`);
for (const key of expectedKeys) {
if (!(key in values)) continue;
const validStructure = structurallyValid(english[key], values[key]);
const unexpected = validStructure
? unexpectedScripts(locale, english[key], values[key])
: [];
if (!validStructure) {
errors.push(`${locale}:${key}: structurally invalid`);
} else if (unexpected.length) {
errors.push(
`${locale}:${key}: unexpected script `
+ unexpected.join(','),
);
} else if (
locale !== 'en'
&& english[key] === values[key]
&& /[A-Za-z]{3}/.test(english[key])
&& !isCodeLiteral(english[key])
&& ![...BRANDS, ...STABLE_TOKENS].includes(english[key])
) {
warnings.push(`${locale}:${key}: unchanged English`);
}
}
process.stdout.write(
`${locale}: keys=${keys.length}/${expectedKeys.length} dir=${meta.dir}\n`,
);
}
for (const warning of warnings.slice(0, 30)) process.stderr.write(`warning: ${warning}\n`);
if (warnings.length > 30) process.stderr.write(`warning: ... ${warnings.length - 30} more\n`);
if (errors.length) {
for (const error of errors.slice(0, 80)) process.stderr.write(`error: ${error}\n`);
if (errors.length > 80) process.stderr.write(`error: ... ${errors.length - 80} more\n`);
throw new Error(`catalog validation failed with ${errors.length} error(s)`);
}
process.stdout.write(`validated locales=${Object.keys(LOCALES).length} keys=${expectedKeys.length} warnings=${warnings.length}\n`);
}
function manifests() {
const source = readJson(path.join(ROOT, 'static', 'manifest.json'));
const english = readJson(path.join(I18N_DIR, 'en.json'));
const descriptionKey = Object.keys(english).find(key => english[key] === source.description);
for (const locale of Object.keys(LOCALES)) {
const values = readJson(path.join(I18N_DIR, `${locale}.json`), {});
writeJson(path.join(ROOT, 'static', `manifest.${locale}.json`), {
...source,
lang: locale,
description: values[descriptionKey] || source.description,
});
}
process.stdout.write(`generated manifests=${Object.keys(LOCALES).length}\n`);
}
async function main() {
const [command = 'validate'] = process.argv.slice(2);
if (command === 'extract') extractCatalog();
else if (command === 'check-sources') extractCatalog({ check: true });
else if (command === 'validate') validate();
else if (command === 'manifests') manifests();
else {
throw new Error('usage: i18n-catalog.mjs extract|check-sources|validate|manifests');
}
}
export {
BRANDS,
STABLE_TOKENS,
LOCALES,
isCodeLiteral,
structurallyValid,
unexpectedScripts,
};
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(error => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exitCode = 1;
});
}

View file

@ -3076,6 +3076,56 @@ def _detect_runaway_call(call_freq, threshold=15):
return sig.split(":", 1)[0] if sig else None
_INTENT_RE = re.compile(
r"(?:^|\n)\s*(?:let me|i'?ll|i will|i need to|we need to|need to|"
r"i should|we should|i must|we must|going to|let's)\s+"
r"(?:tail|check|investigate|look at|see|read|fetch|inspect|"
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
r"register|list|search|find|query|hit|ping|test|use|perform|do)\b[^.\n]{0,140}",
re.IGNORECASE,
)
# Runtime intent supervision is deliberately local. These narrow future-action
# phrases cover the supported locale families without translating text or
# making a second model request. Unknown language remains terminal/no-nudge.
_MULTILINGUAL_INTENT_RE = re.compile(
"|".join(
(
r"(?:déjame|voy a|vamos a|necesito|debo)\s+(?:comprobar|revisar|buscar|ejecutar|verificar|investigar|leer|obtener|capturar|usar)",
r"(?:deixe-me|vou|vamos|preciso|devo)\s+(?:verificar|revisar|buscar|executar|investigar|ler|obter|capturar|usar)",
r"(?:laisse-moi|je vais|nous allons|je dois|je devrais)\s+(?:vérifier|examiner|chercher|exécuter|inspecter|lire|récupérer|utiliser)",
r"(?:lass mich|ich werde|wir werden|ich muss)\s+(?:prüfen|untersuchen|suchen|ausführen|lesen|abrufen|verwenden)",
r"(?:lasciami|vado a|devo|controllerò)\s+(?:controllare|verificare|cercare|eseguire|leggere|recuperare|usare)",
r"(?:laat me|ik ga|ik moet)\s+(?:controleren|onderzoeken|zoeken|uitvoeren|lezen|ophalen|gebruiken)",
r"(?:jag ska|låt mig|jag måste)\s+(?:kontrollera|undersöka|söka|köra|läsa|hämta|använda)",
r"(?:jeg vil|lad mig|jeg skal|jeg må)\s+(?:kontrollere|undersøge|søge|køre|læse|hente|bruge)",
r"(?:jeg skal|la meg|jeg må)\s+(?:sjekke|undersøke|søke|kjøre|lese|hente|bruke)",
r"(?:anna minun|aion|täytyy)\s+(?:tarkistaa|tutkia|hakea|lukea|suorittaa|käyttää)",
r"(?:pozwól mi|sprawdzę|muszę)\s+(?:sprawdzić|zbadać|wyszukać|odczytać|pobrać|uruchomić|użyć)",
r"(?:nechte mě|zkontroluji|musím)\s+(?:zkontrolovat|prozkoumat|vyhledat|přečíst|načíst|spustit|použít)",
r"(?:lasă-mă|voi verifica|trebuie să|am să)\s+(?:verific|caut|citi|obțin|rulez|folosesc)",
r"(?:engedje meg|ellenőrzöm|meg kell)\s+(?:ellenőrizni|megvizsgálni|keresni|olvasni|lekérni|futtatni|használni)",
r"(?:ще|нека)\s+(?:проверя|проверим|потърся|прочета|извлека|стартирам)",
r"(?:я проверю|давайте проверим|мне нужно)\s+(?:проверить|исследовать|найти|прочитать|получить|запустить|использовать)",
r"(?:я перевірю|давайте перевіримо|мені потрібно)\s+(?:перевірити|дослідити|знайти|прочитати|отримати|запустити|використати)",
r"(?:θα|ας)\s+(?:ελέγξω|ελέγξουμε|αναζητήσω|διαβάσω|λάβω|εκτελέσω)",
r"(?:kontrol edeceğim|bırakın kontrol edeyim|bakmam lazım)\s+(?:kontrol et|ara|oku|getir|çalıştır|kullan)",
r"(?:saya akan|biar saya|saya perlu)\s+(?:periksa|selidiki|cari|baca|ambil|jalankan|gunakan)",
r"(?:saya akan|biar saya|saya perlu)\s+(?:semak|siasat|cari|baca|dapatkan|jalankan|gunakan)",
r"(?:سأ(?:تحقق|فحص|بحث|قرأ|جلب|شغل)|(?:سوف|دعني|يجب أن)\s+(?:أتحقق|أفحص|أبحث|أقرأ|أجلب|أشغل))",
r"(?:これから|私が|調べてみます|確認します|取得します|実行します).{0,12}(?:確認|調べ|検索|取得|実行|読み)",
r"(?:확인하겠습니다|확인해 보겠습니다|제가|검색하겠습니다|실행하겠습니다).{0,12}(?:확인|검색|조사|읽|가져오|실행)",
r"(?:我来|让我|我将|需要)\s*(?:检查|查看|调查|搜索|读取|获取|运行|使用)",
r"(?:ฉันจะ|เดี๋ยวฉัน|ต้อง)\s*(?:ตรวจสอบ|ค้นหา|อ่าน|ดึง|เรียกใช้)",
r"(?:tôi sẽ|để tôi|cần)\s+(?:kiểm tra|điều tra|tìm|đọc|lấy|chạy|dùng)",
)
),
re.IGNORECASE,
)
_INTENT_AMBIENT_TOOLS = {"ask_user"}
async def stream_agent_loop(
endpoint_url: str,
model: str,
@ -3846,23 +3896,6 @@ async def stream_agent_loop(
_intent_nudge_count = 0
_MAX_INTENT_NUDGES = 2
# "I said I would, then didn't" detector. The pattern that breaks debug
# loops on weak models (deepseek-v4-flash mid-2026): the model writes
# "Let me tail the output to see the error" and then ends the turn with
# no tool_calls. The intent is sincere but the function call gets dropped.
# Match the common phrasings + an action verb that maps to an available
# tool, so we don't nudge on harmless transitional text like "let me
# know what you think".
_INTENT_RE = re.compile(
r"(?:^|\n)\s*(?:let me|i'?ll|i will|i need to|we need to|need to|"
r"i should|we should|i must|we must|going to|let's)\s+"
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
r"register|adopt|list|search|find|query|hit|ping|test|use|perform|do)"
r"\b[^.\n]{0,140}",
re.IGNORECASE,
)
_awaiting_user = False # set by ask_user → end the turn and wait for a choice
# Document streaming state (persists across rounds)
@ -4428,17 +4461,33 @@ async def stream_agent_loop(
_intent_match = _INTENT_RE.search(_intent_text) if _intent_text else None
# Only nudge when the round REALLY looks like an unfinished
# promise: short response (<400 chars), no fenced code/answer,
# and an action-intent phrase was matched. Long answers that
# happen to contain "let me know" are not stalls.
_looks_like_promise = (
# and either the English fast path or the local multilingual phrase
# table finds a pending action. Long answers are not stalls.
_intent_candidate = (
not guide_only
and _intent_match is not None
and bool(_intent_text)
and len(_intent_text) < 400
and "```" not in _intent_text
)
if _intent_candidate and _intent_match is None:
_selected_action_tools = (
(set(_relevant_tools or ()) | set(_tool_names_sent or ()))
- set(disabled_tools or ())
- _INTENT_AMBIENT_TOOLS
)
if _selected_action_tools:
_intent_match = _MULTILINGUAL_INTENT_RE.search(_intent_text)
_looks_like_promise = (
_intent_candidate
and _intent_match is not None
)
if _looks_like_promise and _intent_nudge_count < _MAX_INTENT_NUDGES:
_intent_nudge_count += 1
_matched_phrase = _intent_match.group(0).strip()
_matched_phrase = (
_intent_match.group(0).strip()
if _intent_match is not None
else _intent_text
)
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}")
_lower_phrase = _matched_phrase.lower()
_cookbook_log_hint = ""
@ -4466,7 +4515,11 @@ async def stream_agent_loop(
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue
if _looks_like_promise:
_matched_phrase = _intent_match.group(0).strip()
_matched_phrase = (
_intent_match.group(0).strip()
if _intent_match is not None
else _intent_text
)
_guard_message = (
"The agent stopped because it repeatedly announced a tool "
"action without making the tool call."

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Binary file not shown.

Binary file not shown.

5376
static/i18n/ar.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/bg.json Normal file

File diff suppressed because it is too large Load diff

94
static/i18n/brands.json Normal file
View file

@ -0,0 +1,94 @@
{
"brands": [
"Odysseus",
"OpenAI",
"ChatGPT",
"Codex",
"Anthropic",
"Claude",
"Google",
"Gemini",
"GitHub",
"Gmail",
"Microsoft",
"Outlook",
"Matrix",
"Discord",
"Slack",
"Notion",
"Box",
"Figma",
"Atlassian",
"Rovo",
"SharePoint",
"Teams",
"Obsidian",
"Hugging Face",
"Ollama",
"SearXNG",
"DuckDuckGo",
"Brave",
"Playwright",
"Chromium",
"llama.cpp",
"Perplexity",
"Copilot",
"Groq",
"Tavily",
"Mistral",
"DeepSeek-V4-Flash",
"DeepSeek-V4",
"DeepSeek",
"Qwen3.5",
"Qwen",
"OpenRouter",
"PyTorch",
"xAI"
],
"stable_tokens": [
"AI",
"API",
"JSON",
"HTML",
"CSS",
"JavaScript",
"TypeScript",
"Python",
"Rust",
"OAuth",
"MCP",
"URL",
"HTTP",
"HTTPS",
"PDF",
"PWA",
"TOTP",
"CalDAV",
"IMAP",
"SMTP",
"WebSocket",
"SSE",
"SQL",
"Markdown",
"CSV",
"ZIP",
"safetensors",
"vLLM",
"SGLang",
"CUDA",
"ROCm",
"GGUF",
"skills.sh",
"MLX",
"NCCL",
"FlashInfer",
"Triton",
"tmux",
"mmproj",
"CardDAV",
"torch",
"rembg",
"llama-cpp-python",
"hf_..."
]
}

5376
static/i18n/cs.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/da.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/de.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/el.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/en.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/es-419.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/es.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/fi.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/fr.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/hu.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/id.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/it.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/ja.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/ko.json Normal file

File diff suppressed because it is too large Load diff

49551
static/i18n/ledger.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/ms.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/nl.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/no.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/pl.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/pt-BR.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/pt.json Normal file

File diff suppressed because it is too large Load diff

167
static/i18n/registry.json Normal file
View file

@ -0,0 +1,167 @@
{
"version": 1,
"source": "https://partner.steamgames.com/doc/store/localization/languages",
"support_level": "full-platform",
"default_locale": "en",
"locales": {
"ar": {
"name": "العربية",
"dir": "rtl"
},
"bg": {
"name": "български език",
"dir": "ltr"
},
"zh-CN": {
"name": "简体中文",
"dir": "ltr"
},
"zh-TW": {
"name": "繁體中文",
"dir": "ltr"
},
"cs": {
"name": "Čeština",
"dir": "ltr"
},
"da": {
"name": "Dansk",
"dir": "ltr"
},
"nl": {
"name": "Nederlands",
"dir": "ltr"
},
"en": {
"name": "English",
"dir": "ltr"
},
"fi": {
"name": "Suomi",
"dir": "ltr"
},
"fr": {
"name": "Français",
"dir": "ltr"
},
"de": {
"name": "Deutsch",
"dir": "ltr"
},
"el": {
"name": "Ελληνικά",
"dir": "ltr"
},
"hu": {
"name": "Magyar",
"dir": "ltr"
},
"id": {
"name": "Bahasa Indonesia",
"dir": "ltr"
},
"it": {
"name": "Italiano",
"dir": "ltr"
},
"ja": {
"name": "日本語",
"dir": "ltr"
},
"ko": {
"name": "한국어",
"dir": "ltr"
},
"ms": {
"name": "Bahasa Melayu",
"dir": "ltr"
},
"no": {
"name": "Norsk",
"dir": "ltr"
},
"pl": {
"name": "Polski",
"dir": "ltr"
},
"pt": {
"name": "Português",
"dir": "ltr"
},
"pt-BR": {
"name": "Português-Brasil",
"dir": "ltr"
},
"ro": {
"name": "Română",
"dir": "ltr"
},
"ru": {
"name": "Русский",
"dir": "ltr"
},
"es": {
"name": "Español-España",
"dir": "ltr"
},
"es-419": {
"name": "Español-Latinoamérica",
"dir": "ltr"
},
"sv": {
"name": "Svenska",
"dir": "ltr"
},
"th": {
"name": "ไทย",
"dir": "ltr"
},
"tr": {
"name": "Türkçe",
"dir": "ltr"
},
"uk": {
"name": "Українська",
"dir": "ltr"
},
"vi": {
"name": "Tiếng Việt",
"dir": "ltr"
}
},
"aliases": {
"zh": "zh-CN",
"zh-Hans": "zh-CN",
"zh-SG": "zh-CN",
"zh-Hant": "zh-TW",
"zh-HK": "zh-TW",
"zh-MO": "zh-TW",
"pt-PT": "pt",
"nb": "no",
"nn": "no",
"nb-NO": "no",
"nn-NO": "no",
"in": "id",
"es-ES": "es",
"es-AR": "es-419",
"es-BO": "es-419",
"es-CL": "es-419",
"es-CO": "es-419",
"es-CR": "es-419",
"es-CU": "es-419",
"es-DO": "es-419",
"es-EC": "es-419",
"es-GT": "es-419",
"es-HN": "es-419",
"es-MX": "es-419",
"es-NI": "es-419",
"es-PA": "es-419",
"es-PE": "es-419",
"es-PR": "es-419",
"es-PY": "es-419",
"es-SV": "es-419",
"es-US": "es-419",
"es-UY": "es-419",
"es-VE": "es-419"
}
}

5376
static/i18n/ro.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/ru.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/sv.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/th.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/tr.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/uk.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/vi.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/zh-CN.json Normal file

File diff suppressed because it is too large Load diff

5376
static/i18n/zh-TW.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -178,31 +178,43 @@
'/tasks': 'Tasks — Odysseus',
'/library': 'Library — Odysseus',
};
if (titles[path]) document.title = titles[path];
// Per-route Android home-screen icon. We swap the <link rel="manifest">
// to a per-page Blob URL with this route's SVG icon — that way "Add to
// Home Screen" picks up the route-specific glyph instead of the shared
// boat logo. Falls back silently on browsers without Blob URL support.
// boat logo. The i18n runtime calls this updater again after each locale
// change so the Blob does not strand English metadata.
try {
if (inner && typeof Blob !== 'undefined') {
var pwa = {
name: (titles[path] || 'Odysseus'),
short_name: (titles[path] || 'Odysseus').split('—')[0].trim(),
start_url: path,
scope: '/',
display: 'standalone',
background_color: '#0e0e10',
theme_color: ac,
icons: [
{ src: href, sizes: '192x192', type: 'image/svg+xml', purpose: 'any maskable' },
{ src: href, sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
],
var routeManifestUrl = '';
window.__odysseusUpdateRouteManifest = function(locale, translate) {
var translateExact = typeof translate === 'function' ? translate : function(value) { return value; };
var titleSource = titles[path] || 'Odysseus';
var shortSource = titleSource.split('—')[0].trim();
var localizedShort = translateExact(shortSource);
var pwa = {
name: titleSource === shortSource ? localizedShort : localizedShort + ' — Odysseus',
short_name: localizedShort,
lang: locale || 'en',
start_url: path,
scope: '/',
display: 'standalone',
background_color: '#0e0e10',
theme_color: ac,
icons: [
{ src: href, sizes: '192x192', type: 'image/svg+xml', purpose: 'any maskable' },
{ src: href, sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
],
};
var blob = new Blob([JSON.stringify(pwa)], { type: 'application/manifest+json' });
var url = URL.createObjectURL(blob);
var ml = document.querySelector("link[rel='manifest']");
if (!ml) { ml = document.createElement('link'); ml.rel = 'manifest'; document.head.appendChild(ml); }
ml.href = url;
document.title = pwa.name;
if (routeManifestUrl) URL.revokeObjectURL(routeManifestUrl);
routeManifestUrl = url;
};
var blob = new Blob([JSON.stringify(pwa)], { type: 'application/manifest+json' });
var url = URL.createObjectURL(blob);
var ml = document.querySelector("link[rel='manifest']");
if (!ml) { ml = document.createElement('link'); ml.rel = 'manifest'; document.head.appendChild(ml); }
ml.href = url;
window.__odysseusUpdateRouteManifest('en');
}
} catch(_) {}
} catch(e){}
@ -1766,6 +1778,14 @@
<!-- ═══ APPEARANCE TAB ═══ -->
<div data-settings-panel="appearance" class="settings-appearance-panel hidden">
<div class="admin-card">
<h2>Language</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Choose the language used by Odysseus. Your choice is saved in this browser.</div>
<div class="settings-row">
<label class="settings-label" for="set-interface-language">Interface language</label>
<select id="set-interface-language" class="settings-select" data-language-select style="width:382.5px;max-width:100%;margin-left:auto"></select>
</div>
</div>
<div class="admin-card" style="padding-bottom:6px;">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>Sidebar<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Sidebar to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
<div class="vis-toggles">
@ -2501,6 +2521,7 @@
<!-- Load modules in this order -->
<script type="module" src="/static/js/i18n.js"></script>
<script type="module" src="/static/js/storage.js"></script>
<script type="module" src="/static/js/ui.js"></script>
<script type="module" src="/static/js/markdown.js"></script>

View file

@ -120,6 +120,8 @@ let _emails = [];
let _currentFolder = 'INBOX';
let _offset = 0;
let _total = 0;
const _EMPTY_FOLDER_ROLES = Object.freeze(Object.create(null));
let _folderRoles = _EMPTY_FOLDER_ROLES;
// Replying to an email marks the source \Answered server-side and fires
// `email-answered`. Reflect it live in the inbox list so it shows as done
@ -441,57 +443,110 @@ async function loadFolders() {
const data = await res.json();
const select = document.getElementById('email-folder-select');
if (!select || !data.folders) return;
_populateFolderSelect(select, data.folders);
_folderRoles = data.roles && typeof data.roles === 'object'
? data.roles
: _EMPTY_FOLDER_ROLES;
_populateFolderSelect(select, data.folders, _folderRoles);
} catch (e) {
console.error('Failed to load folders:', e);
}
}
export function sortedFolders(folders) {
const roleOf = (folder) => {
const f = String(folder || '').toLowerCase();
if (f === 'inbox') return 'inbox';
if (f.includes('sent')) return 'sent';
if (f.includes('starred') || f.includes('flagged')) return 'starred';
if (f.includes('draft')) return 'drafts';
if (f.includes('all mail') || f.includes('archive')) return 'archive';
if (f.includes('spam') || f.includes('junk')) return 'junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'trash';
return '';
};
const roleOrder = ['inbox', 'sent', 'starred', 'archive', 'junk', 'trash', 'drafts'];
const _LEGACY_FOLDER_ROLES = new Map([
['inbox', 'inbox'],
['sent', 'sent'],
['sent mail', 'sent'],
['sent items', 'sent'],
['inbox.sent', 'sent'],
['[gmail]/sent mail', 'sent'],
['[google mail]/sent mail', 'sent'],
['starred', 'flagged'],
['flagged', 'flagged'],
['[gmail]/starred', 'flagged'],
['[google mail]/starred', 'flagged'],
['draft', 'drafts'],
['drafts', 'drafts'],
['[gmail]/drafts', 'drafts'],
['[google mail]/drafts', 'drafts'],
['all mail', 'all'],
['[gmail]/all mail', 'all'],
['[google mail]/all mail', 'all'],
['archive', 'archive'],
['archives', 'archive'],
['spam', 'junk'],
['junk', 'junk'],
['[gmail]/spam', 'junk'],
['[google mail]/spam', 'junk'],
['trash', 'trash'],
['bin', 'trash'],
['deleted messages', 'trash'],
['deleted items', 'trash'],
['[gmail]/trash', 'trash'],
['[google mail]/trash', 'trash'],
]);
export function folderRole(folder, roles = _folderRoles) {
const raw = String(folder || '');
if (roles && typeof roles === 'object' && Object.hasOwn(roles, raw)) {
return String(roles[raw] || '');
}
return _LEGACY_FOLDER_ROLES.get(raw.toLowerCase()) || '';
}
export function sortedFolders(folders, roles = _folderRoles) {
const roleOrder = ['inbox', 'sent', 'flagged', 'all', 'archive', 'junk', 'trash', 'drafts'];
const found = new Map();
const others = [];
for (const f of folders) {
const role = roleOf(f);
const role = folderRole(f, roles);
if (role && !found.has(role)) found.set(role, f);
else others.push(f);
}
return { priority: roleOrder.map(role => found.get(role)).filter(Boolean), others };
}
export function folderDisplayName(folder) {
const raw = String(folder || '');
const f = raw.toLowerCase();
if (f === 'inbox') return 'INBOX';
if (f.includes('all mail')) return 'Archive / All Mail';
if (f.includes('archive')) return 'Archive';
if (f.includes('spam')) return 'Spam';
if (f.includes('junk')) return 'Junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'Trash';
if (f.includes('sent')) return 'Sent';
if (f.includes('draft')) return 'Drafts';
return raw;
function _folderLabel(key, fallback) {
const translated = window.odysseusI18n?.t?.(key);
return translated && translated !== key ? translated : fallback;
}
function _populateFolderSelect(select, folders) {
const _FOLDER_LABELS = Object.freeze({
inbox: ['ui.email.folder.inbox', 'INBOX'],
sent: ['ui.email.folder.sent', 'Sent'],
flagged: ['ui.email.folder.flagged', 'Starred'],
all: ['ui.email.folder.all', 'All Mail'],
archive: ['ui.email.folder.archive', 'Archive'],
junk: ['ui.email.folder.junk', 'Junk'],
trash: ['ui.email.folder.trash', 'Trash'],
drafts: ['ui.email.folder.drafts', 'Drafts'],
});
export function folderLabelKey(folder, roles = _folderRoles) {
const role = typeof roles === 'string' ? roles : folderRole(folder, roles);
return _FOLDER_LABELS[role]?.[0] || '';
}
export function folderDisplayName(folder, roles = _folderRoles) {
const raw = String(folder || '');
const role = typeof roles === 'string' ? roles : folderRole(raw, roles);
const label = _FOLDER_LABELS[role];
return label ? _folderLabel(label[0], label[1]) : raw;
}
function _setFolderOptionLabel(option, folder, roles) {
const key = folderLabelKey(folder, roles);
if (key) option.setAttribute('data-i18n', key);
option.textContent = folderDisplayName(folder, roles);
}
function _populateFolderSelect(select, folders, roles = _folderRoles) {
select.innerHTML = '';
const { priority, others } = sortedFolders(folders);
const { priority, others } = sortedFolders(folders, roles);
for (const folder of priority) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
_setFolderOptionLabel(opt, folder, roles);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}
@ -506,7 +561,7 @@ function _populateFolderSelect(select, folders) {
for (const folder of others) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
_setFolderOptionLabel(opt, folder, roles);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}

View file

@ -5,7 +5,12 @@
import spinnerModule from './spinner.js';
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
import {
folderDisplayName,
folderLabelKey,
folderRole,
sortedFolders,
} from './emailInbox.js?v=20260722emailfastindex1';
import settingsModule from './settings.js';
import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
@ -39,6 +44,12 @@ let _libAccountsLoadedAt = 0;
const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000;
let _accountUnreadSeq = 0;
let _accountUnreadState = new Map(); // account_id -> { unreadCount, maxUid }
const _libFolderRolesByAccount = new Map();
const _EMPTY_FOLDER_ROLES = Object.freeze(Object.create(null));
function _activeFolderRoles() {
return _libFolderRolesByAccount.get(state._libAccountId || '') || _EMPTY_FOLDER_ROLES;
}
const _EMAIL_SETTINGS_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`;
const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}';
@ -859,7 +870,7 @@ function _syncEmailReadState(uid, isRead = true) {
}
if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return;
const isSentFolder = /sent/i.test(state._libFolder || '');
const isSentFolder = folderRole(state._libFolder, _activeFolderRoles()) === 'sent';
if (isSentFolder) return;
const senderName = match ? (match.from_name || match.from_address || '') : '';
const dot = document.createElement('span');
@ -3262,6 +3273,10 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) {
const sel = document.getElementById('email-lib-folder');
if (!sel || !data.folders) return;
state._libFolders = data.folders;
_libFolderRolesByAccount.set(
accountAtStart,
data.roles && typeof data.roles === 'object' ? data.roles : _EMPTY_FOLDER_ROLES,
);
if (resetMissing && state._libFolder !== '__scheduled__' && !data.folders.includes(state._libFolder)) {
state._libFolder = data.folders.includes('INBOX') ? 'INBOX' : (data.folders[0] || 'INBOX');
state._libFilter = 'all';
@ -3278,11 +3293,14 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) {
_syncReminderClearButton();
}
sel.innerHTML = '';
const { priority, others } = sortedFolders(data.folders);
const roles = _activeFolderRoles();
const { priority, others } = sortedFolders(data.folders, roles);
for (const f of priority) {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = folderDisplayName(f);
const key = folderLabelKey(f, roles);
if (key) opt.setAttribute('data-i18n', key);
opt.textContent = folderDisplayName(f, roles);
if (f === state._libFolder) opt.selected = true;
sel.appendChild(opt);
}
@ -3295,7 +3313,9 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) {
for (const f of others) {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = folderDisplayName(f);
const key = folderLabelKey(f, roles);
if (key) opt.setAttribute('data-i18n', key);
opt.textContent = folderDisplayName(f, roles);
if (f === state._libFolder) opt.selected = true;
sel.appendChild(opt);
}
@ -3306,7 +3326,13 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) {
sel.appendChild(sep2);
const schedOpt = document.createElement('option');
schedOpt.value = '__scheduled__';
schedOpt.textContent = 'Scheduled';
schedOpt.setAttribute('data-i18n', 'ui.email.folder.scheduled');
const scheduledLabel = window.odysseusI18n?.t?.('ui.email.folder.scheduled');
schedOpt.textContent = (
scheduledLabel && scheduledLabel !== 'ui.email.folder.scheduled'
? scheduledLabel
: 'Scheduled'
);
if (state._libFolder === '__scheduled__') schedOpt.selected = true;
sel.appendChild(schedOpt);
sel.value = state._libFolder;
@ -3315,35 +3341,26 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) {
function _crossFolderCandidates() {
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
const pick = (patterns, fallback) => {
for (const p of patterns) {
const direct = lower.get(String(p).toLowerCase());
if (direct) return direct;
}
const match = available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase())));
return match || fallback;
};
const roles = _activeFolderRoles();
const pick = (role, fallback = '') => available.find(f => folderRole(f, roles) === role) || fallback;
const candidates = [
pick(['INBOX'], 'INBOX'),
pick(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], '[Gmail]/Sent Mail'),
pick(['Archive', '[Gmail]/All Mail', 'All Mail'], '[Gmail]/All Mail'),
pick('inbox', 'INBOX'),
pick('sent', 'Sent'),
pick('all'),
pick('archive'),
];
if (!candidates[2] && !candidates[3]) candidates.push('All Mail', 'Archive');
return Array.from(new Set(candidates.filter(Boolean)));
}
function _findEmailFolder(patterns, fallback) {
function _findEmailFolder(role, fallback) {
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
for (const p of patterns) {
const direct = lower.get(String(p).toLowerCase());
if (direct) return direct;
}
return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback;
const roles = _activeFolderRoles();
return available.find(f => folderRole(f, roles) === role) || fallback;
}
function _sentFolderName() {
return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent');
return _findEmailFolder('sent', 'Sent');
}
function _deriveSearchScope(rawQuery) {
@ -4859,7 +4876,8 @@ function _createCard(em) {
// real folder while the visible folder selector still says INBOX, so use the
// email's folder first.
const cardFolder = em.folder || state._libFolder || 'INBOX';
const isSentFolderEarly = /sent/i.test(cardFolder);
const roles = _activeFolderRoles();
const isSentFolderEarly = folderRole(cardFolder, roles) === 'sent';
let senderName;
let senderAddress;
if (isSentFolderEarly) {
@ -4938,8 +4956,7 @@ function _createCard(em) {
}
// Done check + unread dot stay next to the subject on the left.
const isSentFolder = /sent/i.test(cardFolder);
if (!isSentFolder) {
if (!isSentFolderEarly) {
const doneCheck = document.createElement('span');
doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
doneCheck.title = em.is_answered ? 'Mark not done' : 'Mark done';
@ -5028,10 +5045,13 @@ function _createCard(em) {
meta.className = 'memory-item-meta';
meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
const showFolderChip = !!(_libSearchHadResults && cardFolder);
const prettyFolder = folderDisplayName(cardFolder);
const sentChip = isSentFolderEarly ? '<span class="email-sent-chip" title="Sent email">Sent</span>' : '';
const prettyFolder = folderDisplayName(cardFolder, roles);
const folderKey = folderLabelKey(cardFolder, roles);
const sentChip = isSentFolderEarly
? '<span class="email-sent-chip" data-i18n="ui.email.folder.sent" title="Sent email" data-i18n-title="ui.sent.email">Sent</span>'
: '';
const folderChip = showFolderChip && !isSentFolderEarly
? `<span class="email-folder-chip" title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
? `<span class="email-folder-chip"${folderKey ? ` data-i18n="${folderKey}"` : ''} title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
: '';
const senderPrefix = isSentFolderEarly ? 'to ' : '';
meta.innerHTML = `${sentChip}${folderChip}<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
@ -5480,8 +5500,8 @@ function _setBubblesDisabled(v) {
function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const folder = String(data?.folder || '');
const isSentFolder = folderRole(folder, _activeFolderRoles()) === 'sent';
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
@ -7680,7 +7700,10 @@ function _showCardMenu(em, anchor) {
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const isSentFolder = /sent/i.test(state._libFolder);
const isSentFolder = folderRole(
em.folder || state._libFolder,
_activeFolderRoles(),
) === 'sent';
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
const actions = [

586
static/js/i18n.js Normal file
View file

@ -0,0 +1,586 @@
const STORAGE_KEY = 'odysseus.locale';
const RESOURCE_ROOT = '/static/i18n';
const TRANSLATABLE_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'aria-description', 'alt'];
// Never run legacy string matching over user-authored or model-authored text.
const USER_CONTENT_SELECTOR = [
'[data-user-content]',
'.msg .body',
'.document-content',
'.document-title',
'.note-editor',
'.note-content-preview',
'.note-title',
'.memory-item-content',
'.session-title',
'.email-reader-body',
'.email-subject',
'.email-sender',
'.research-job-report-body',
'.task-log-row-body',
].join(',');
const SKIP_SELECTOR = [
'script',
'style',
'code',
'pre',
'textarea',
'[contenteditable]',
'[data-i18n-skip]',
USER_CONTENT_SELECTOR,
].join(',');
const SEMANTIC_SELECTOR = [
'[data-i18n]',
...TRANSLATABLE_ATTRIBUTES.map(attribute => `[data-i18n-${attribute}]`),
].join(',');
const USER_DIRECTION_SELECTOR = [
'input',
'textarea',
'[contenteditable]',
USER_CONTENT_SELECTOR,
].join(',');
const CSS_MESSAGES = {
'--i18n-css-copy': 'Copy',
'--i18n-css-copied': '✓ Copied',
'--i18n-css-edit': 'Edit',
'--i18n-css-editing': 'EDITING',
'--i18n-css-save': 'Save',
'--i18n-css-run': 'Run',
'--i18n-css-enabled': 'Enabled',
'--i18n-css-disabled': 'Disabled',
'--i18n-css-show-more': 'Show more',
'--i18n-css-show-less': 'Show less',
'--i18n-css-archive': 'Archive',
'--i18n-css-no-todos': 'No todos',
'--i18n-css-drop-to-attach': 'Drop to attach',
'--i18n-css-write-email': 'Write your email…',
'--i18n-css-planning-goal': 'AI is planning your goal…',
'--i18n-css-no-title': 'No title',
};
let registry = null;
let english = {};
let catalog = {};
let locale = 'en';
let exactTranslations = new Map();
let templateTranslations = [];
let observer = null;
let applying = false;
let localeRequest = 0;
const catalogRequests = new Map();
const nativeDialogs = typeof window === 'undefined' ? null : {
alert: window.alert.bind(window),
confirm: window.confirm.bind(window),
prompt: window.prompt.bind(window),
};
const enrolledText = new Set();
const textState = new WeakMap();
const enrolledAttributeElements = new Set();
const attributeState = new WeakMap();
function safeStorageGet(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function safeStorageSet(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// Storage may be blocked by browser privacy settings; the active locale
// still applies for this page.
}
}
async function fetchJson(name) {
const response = await fetch(`${RESOURCE_ROOT}/${name}.json`, { cache: 'no-cache' });
if (!response.ok) throw new Error(`Unable to load language resource: ${name}`);
return response.json();
}
function fetchCatalog(name) {
if (!catalogRequests.has(name)) {
catalogRequests.set(
name,
fetchJson(name).catch(error => {
catalogRequests.delete(name);
throw error;
}),
);
}
return catalogRequests.get(name);
}
export function interpolate(value, parameters = {}) {
return String(value).replace(
/\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}/g,
(placeholder, name) => (
Object.hasOwn(parameters, name) ? String(parameters[name]) : placeholder
),
);
}
export function matchLocale(requestedLocales, localeRegistry) {
const aliases = localeRegistry?.aliases || {};
const locales = localeRegistry?.locales || {};
for (const requested of requestedLocales || []) {
if (typeof requested !== 'string' || !requested) continue;
const tag = requested;
const lower = tag.toLowerCase();
const exact = Object.keys(locales).find(id => id.toLowerCase() === lower);
if (exact) return exact;
const alias = Object.entries(aliases).find(([id]) => id.toLowerCase() === lower);
if (alias && Object.hasOwn(locales, alias[1])) return alias[1];
if (lower.startsWith('zh-hant') && Object.hasOwn(locales, 'zh-TW')) return 'zh-TW';
if (lower.startsWith('zh-hans') && Object.hasOwn(locales, 'zh-CN')) return 'zh-CN';
const base = lower.split('-')[0];
const baseLocale = Object.keys(locales).find(id => id.toLowerCase() === base);
if (baseLocale) return baseLocale;
const baseAlias = Object.entries(aliases).find(([id]) => id.toLowerCase() === base);
if (baseAlias && Object.hasOwn(locales, baseAlias[1])) return baseAlias[1];
}
const fallback = localeRegistry?.default_locale;
if (typeof fallback === 'string' && Object.hasOwn(locales, fallback)) return fallback;
return Object.hasOwn(locales, 'en') ? 'en' : (Object.keys(locales)[0] || 'en');
}
function lookupTranslation(key) {
if (Object.hasOwn(catalog, key)) return catalog[key];
if (Object.hasOwn(english, key)) return english[key];
return key;
}
function localeMetadata(id) {
const locales = registry?.locales || {};
return Object.hasOwn(locales, id) ? locales[id] : null;
}
function rebuildLegacyIndex() {
exactTranslations = new Map();
templateTranslations = [];
for (const [key, source] of Object.entries(english)) {
const target = Object.hasOwn(catalog, key) ? catalog[key] : source;
exactTranslations.set(source, target);
const parameters = [];
let cursor = 0;
let pattern = '';
for (const match of source.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}/g)) {
pattern += source
.slice(cursor, match.index)
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
pattern += '([\\s\\S]*?)';
parameters.push(match[1]);
cursor = match.index + match[0].length;
}
const literalLength = source.length - parameters.reduce(
(total, name) => total + name.length + 2,
0,
);
if (parameters.length && literalLength >= 8 && /[A-Za-z]{4}/u.test(source)) {
pattern += source.slice(cursor).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
templateTranslations.push({
pattern: new RegExp(`^${pattern}$`, 'u'),
parameters,
target,
literalLength,
});
}
}
templateTranslations.sort((left, right) => right.literalLength - left.literalLength);
}
function translateLegacy(value) {
if (locale === 'en') return value;
return exactTranslations.has(value) ? exactTranslations.get(value) : value;
}
function translateMessage(value) {
if (locale === 'en' || exactTranslations.has(value)) return translateLegacy(value);
for (const template of templateTranslations) {
const match = String(value).match(template.pattern);
if (!match) continue;
const parameters = Object.create(null);
template.parameters.forEach((name, index) => {
parameters[name] = match[index + 1];
});
return interpolate(template.target, parameters);
}
return value;
}
function shouldSkipLegacy(node) {
const parent = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return !parent || Boolean(parent.closest(SKIP_SELECTOR));
}
function semanticParameters(element) {
const parameters = Object.create(null);
for (const attribute of element.attributes) {
if (!attribute.name.startsWith('data-i18n-param-')) continue;
parameters[attribute.name.slice('data-i18n-param-'.length)] = attribute.value;
}
return parameters;
}
function applySemantic(element) {
if (element.closest('[data-i18n-skip]')) return;
const parameters = semanticParameters(element);
const textKey = element.getAttribute('data-i18n');
if (textKey) {
const translated = interpolate(lookupTranslation(textKey), parameters);
if (element.textContent !== translated) element.textContent = translated;
}
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
const key = element.getAttribute(`data-i18n-${attribute}`);
if (!key) continue;
const translated = interpolate(lookupTranslation(key), parameters);
if (element.getAttribute(attribute) !== translated) {
element.setAttribute(attribute, translated);
}
}
}
function captureTextNode(node) {
if (
shouldSkipLegacy(node)
|| node.parentElement.closest('[data-i18n]')
|| !node.nodeValue.trim()
) return;
enrolledText.add(node);
textState.set(node, { source: node.nodeValue, lastRendered: node.nodeValue });
}
function captureElementAttributes(element) {
if (shouldSkipLegacy(element)) return;
const attributes = new Map();
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
if (
!element.hasAttribute(attribute)
|| element.hasAttribute(`data-i18n-${attribute}`)
) continue;
const source = element.getAttribute(attribute);
attributes.set(attribute, { source, lastRendered: source });
}
if (!attributes.size) return;
enrolledAttributeElements.add(element);
attributeState.set(element, attributes);
}
function captureStaticTree(root = document.documentElement) {
if (root.nodeType === Node.ELEMENT_NODE) captureElementAttributes(root);
if (root.nodeType === Node.TEXT_NODE) captureTextNode(root);
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
if (node.nodeType === Node.ELEMENT_NODE && node.matches(SKIP_SELECTOR)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
},
);
while (walker.nextNode()) {
if (walker.currentNode.nodeType === Node.TEXT_NODE) {
captureTextNode(walker.currentNode);
} else {
captureElementAttributes(walker.currentNode);
}
}
}
function translatedStaticText(source) {
const leading = source.match(/^\s*/u)?.[0] || '';
const trailing = source.match(/\s*$/u)?.[0] || '';
const core = source.slice(leading.length, source.length - trailing.length);
const translated = translateLegacy(core);
return translated === core ? source : `${leading}${translated}${trailing}`;
}
function translateEnrolled() {
for (const node of enrolledText) {
const state = textState.get(node);
if (!node.isConnected || !state) {
enrolledText.delete(node);
continue;
}
const current = node.nodeValue;
if (current !== state.source && current !== state.lastRendered) {
enrolledText.delete(node);
textState.delete(node);
continue;
}
const translated = translatedStaticText(state.source);
if (translated !== current) node.nodeValue = translated;
state.lastRendered = translated;
}
for (const element of enrolledAttributeElements) {
const states = attributeState.get(element);
if (!element.isConnected || !states) {
enrolledAttributeElements.delete(element);
continue;
}
for (const [attribute, state] of states) {
const current = element.getAttribute(attribute);
if (
element.hasAttribute(`data-i18n-${attribute}`)
|| (current !== state.source && current !== state.lastRendered)
) {
states.delete(attribute);
continue;
}
const translated = translateLegacy(state.source);
if (translated !== current) element.setAttribute(attribute, translated);
state.lastRendered = translated;
}
if (!states.size) {
enrolledAttributeElements.delete(element);
attributeState.delete(element);
}
}
}
function markUserDirections(root) {
if (root.nodeType !== Node.ELEMENT_NODE) return;
const elements = root.matches(USER_DIRECTION_SELECTOR)
? [root, ...root.querySelectorAll(USER_DIRECTION_SELECTOR)]
: [...root.querySelectorAll(USER_DIRECTION_SELECTOR)];
for (const element of elements) {
if (!element.hasAttribute('dir')) element.setAttribute('dir', 'auto');
}
}
function applySemanticTree(root) {
if (root.nodeType === Node.TEXT_NODE) {
const owner = root.parentElement?.closest(SEMANTIC_SELECTOR);
if (owner) applySemantic(owner);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE) return;
if (root.matches(SEMANTIC_SELECTOR)) applySemantic(root);
for (const element of root.querySelectorAll(SEMANTIC_SELECTOR)) applySemantic(element);
}
function renderDocument() {
applying = true;
try {
translateEnrolled();
applySemanticTree(document.documentElement);
markUserDirections(document.documentElement);
} finally {
queueMicrotask(() => {
applying = false;
});
}
}
function hydrateLanguageControls() {
for (const select of document.querySelectorAll('[data-language-select]')) {
const priorValue = select.value;
select.replaceChildren();
for (const [id, meta] of Object.entries(registry.locales)) {
const option = document.createElement('option');
option.value = id;
option.textContent = meta.name;
option.lang = id;
option.dir = meta.dir;
option.setAttribute('data-i18n-skip', '');
select.appendChild(option);
}
select.value = Object.hasOwn(registry.locales, locale) ? locale : priorValue;
}
}
function syncCssMessages() {
for (const [property, source] of Object.entries(CSS_MESSAGES)) {
document.documentElement.style.setProperty(property, JSON.stringify(translateLegacy(source)));
}
}
function announceLanguageChange() {
let status = document.getElementById('i18n-language-status');
if (!status) {
status = document.createElement('div');
status.id = 'i18n-language-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0';
document.body.appendChild(status);
}
status.lang = locale;
status.dir = localeMetadata(locale)?.dir || 'ltr';
status.textContent = translateLegacy('Language changed.');
}
async function setLocale(nextLocale, { persist = true, announce = persist } = {}) {
const request = ++localeRequest;
const locales = registry?.locales || {};
const configuredFallback = registry?.default_locale;
const fallback = (
typeof configuredFallback === 'string'
&& Object.hasOwn(locales, configuredFallback)
)
? configuredFallback
: (Object.hasOwn(locales, 'en') ? 'en' : (Object.keys(locales)[0] || 'en'));
if (typeof nextLocale !== 'string' || !Object.hasOwn(locales, nextLocale)) {
nextLocale = fallback;
}
const nextCatalog = nextLocale === 'en' ? english : await fetchCatalog(nextLocale);
if (request !== localeRequest) return locale;
locale = nextLocale;
catalog = nextCatalog;
rebuildLegacyIndex();
document.documentElement.lang = locale;
document.documentElement.dir = localeMetadata(locale)?.dir || 'ltr';
const manifest = document.querySelector('link[rel="manifest"]');
if (typeof window.__odysseusUpdateRouteManifest === 'function') {
window.__odysseusUpdateRouteManifest(locale, translateLegacy);
} else if (manifest) {
manifest.href = `/static/manifest.${locale}.json`;
}
if (persist) safeStorageSet(STORAGE_KEY, locale);
renderDocument();
hydrateLanguageControls();
syncCssMessages();
if (announce) announceLanguageChange();
document.dispatchEvent(new CustomEvent('odysseus:languagechange', {
detail: { locale },
}));
return locale;
}
async function init() {
captureStaticTree();
markUserDirections(document.documentElement);
[registry, english] = await Promise.all([fetchJson('registry'), fetchJson('en')]);
const saved = safeStorageGet(STORAGE_KEY);
const requested = saved
? matchLocale([saved], registry)
: matchLocale([registry.default_locale], registry);
try {
await setLocale(requested, { persist: false, announce: false });
} catch (error) {
if (requested === registry.default_locale) throw error;
console.warn(`[i18n] unable to load ${requested}; using ${registry.default_locale}`, error);
await setLocale(registry.default_locale, { persist: false, announce: false });
}
// Existing modules still have a few native-dialog fallbacks. Translation is
// catalog-gated: unknown text is returned byte-for-byte, and placeholders
// preserve dynamic values.
window.alert = message => nativeDialogs.alert(translateMessage(message));
window.confirm = message => nativeDialogs.confirm(translateMessage(message));
window.prompt = (message, defaultValue) => (
nativeDialogs.prompt(translateMessage(message), defaultValue)
);
document.addEventListener('change', event => {
if (!(event.target instanceof Element) || !event.target.matches('[data-language-select]')) return;
setLocale(event.target.value).catch(error => {
console.error('[i18n]', error);
hydrateLanguageControls();
});
});
observer = new MutationObserver(records => {
if (applying) return;
applying = true;
try {
for (const record of records) {
if (record.type === 'characterData') {
applySemanticTree(record.target);
continue;
}
if (record.type === 'attributes') {
if (
record.attributeName === 'data-i18n'
|| TRANSLATABLE_ATTRIBUTES.some(
attribute => record.attributeName === `data-i18n-${attribute}`,
)
|| record.attributeName.startsWith('data-i18n-param-')
) {
applySemantic(record.target);
}
if (
record.attributeName === 'contenteditable'
|| record.attributeName === 'data-user-content'
|| record.attributeName === 'class'
) {
markUserDirections(record.target);
}
continue;
}
for (const node of record.addedNodes) {
applySemanticTree(node);
markUserDirections(node);
}
}
} finally {
queueMicrotask(() => {
applying = false;
});
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
}
function applyStoredDocumentMetadata() {
const saved = safeStorageGet(STORAGE_KEY);
if (!saved || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/u.test(saved)) return;
document.documentElement.lang = saved;
document.documentElement.dir = saved === 'ar' ? 'rtl' : 'ltr';
}
if (typeof document !== 'undefined') applyStoredDocumentMetadata();
const ready = typeof document === 'undefined'
? Promise.resolve()
: init().catch(error => console.error('[i18n]', error));
if (typeof window !== 'undefined') {
window.odysseusI18n = {
ready,
get locale() {
return locale;
},
get locales() {
return registry?.locales || {};
},
setLocale,
t(key, parameters) {
return interpolate(lookupTranslation(key), parameters);
},
translateLegacy,
translateMessage,
plural(count, forms) {
const category = new Intl.PluralRules(locale).select(count);
return forms[category] ?? forms.other;
},
formatNumber(value, options) {
return new Intl.NumberFormat(locale, options).format(value);
},
formatDate(value, options) {
return new Intl.DateTimeFormat(locale, options).format(value);
},
formatRelative(value, unit, options) {
return new Intl.RelativeTimeFormat(locale, options).format(value, unit);
},
formatList(values, options) {
return new Intl.ListFormat(locale, options).format(values);
},
compare(left, right, options) {
return new Intl.Collator(locale, options).compare(left, right);
},
};
}

View file

@ -50,7 +50,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
const KEY = 'odysseus-auth-user';
const cachedUser = localStorage.getItem(KEY);
if (cachedUser && cachedUser !== liveUser) {
const _keepKeys = new Set(['odysseus-last-user', KEY]);
const _keepKeys = new Set(['odysseus-last-user', 'odysseus.locale', KEY]);
const toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);

View file

@ -16,6 +16,9 @@ let _authPolicy = { password_min_length: 8 };
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
function tr(value) {
return window.odysseusI18n?.translateMessage?.(String(value)) ?? value;
}
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
@ -2138,8 +2141,8 @@ function initAccount() {
const nameEl = el('settings-account-username');
const roleEl = el('settings-account-role');
const avatarEl = el('settings-account-avatar');
if (nameEl) nameEl.textContent = d.username || 'Unknown';
if (roleEl) roleEl.textContent = d.is_admin ? 'Admin' : 'User';
if (nameEl) nameEl.textContent = d.username || tr('Unknown');
if (roleEl) roleEl.textContent = tr(d.is_admin ? 'Admin' : 'User');
if (avatarEl) {
const initial = (d.username || '?')[0].toUpperCase();
avatarEl.textContent = initial;
@ -2153,7 +2156,7 @@ function initAccount() {
if (!policy) return;
_authPolicy = policy;
const pwNew = el('settings-pw-new');
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
if (pwNew) pwNew.placeholder = tr(`New password (min ${policy.password_min_length})`);
}).catch(() => {});
// Change password
@ -2165,9 +2168,9 @@ function initAccount() {
const nw = el('settings-pw-new').value;
const conf = el('settings-pw-confirm').value;
msgEl.style.color = '';
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
if (!cur || !nw) { msgEl.textContent = tr('Fill in all fields'); msgEl.style.color = 'var(--red)'; return; }
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = tr(`Min ${_authPolicy.password_min_length} characters`); msgEl.style.color = 'var(--red)'; return; }
if (nw !== conf) { msgEl.textContent = tr('Passwords don\'t match'); msgEl.style.color = 'var(--red)'; return; }
saveBtn.disabled = true;
try {
const res = await fetch('/api/auth/change-password', {
@ -2177,13 +2180,13 @@ function initAccount() {
});
if (!res.ok) { const d = await res.json(); throw new Error(d.detail || 'Failed'); }
msgEl.style.color = 'var(--green)';
msgEl.textContent = 'Password updated';
msgEl.textContent = tr('Password updated');
el('settings-pw-current').value = '';
el('settings-pw-new').value = '';
el('settings-pw-confirm').value = '';
} catch (e) {
msgEl.style.color = 'var(--red)';
msgEl.textContent = e.message;
msgEl.textContent = tr(e.message);
} finally {
saveBtn.disabled = false;
}
@ -2201,18 +2204,18 @@ function initAccount() {
// 2FA is ON — show disable option
tfaContent.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="color:var(--color-save-green, #4caf50);font-size:12px;font-weight:600;">&#x2713; Enabled</span>
<span style="font-size:11px;opacity:0.5;">Authenticator app required on login</span>
<span data-i18n="ui.enabled.df174a3f" style="color:var(--color-save-green, #4caf50);font-size:12px;font-weight:600;">Enabled</span>
<span data-i18n="ui.authenticator.app.required.on.login" style="font-size:11px;opacity:0.5;">Authenticator app required on login</span>
</div>
<input id="tfa-disable-pw" type="password" placeholder="Enter password to disable" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;margin-bottom:6px;">
<input id="tfa-disable-pw" type="password" placeholder="Enter password to disable" data-i18n-placeholder="ui.enter.password.to.disable" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;margin-bottom:6px;">
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-disable-btn" style="opacity:0.7;">Disable 2FA</button>
<button class="admin-btn-add" id="tfa-disable-btn" data-i18n="ui.disable.2fa" style="opacity:0.7;">Disable 2FA</button>
</div>`;
el('tfa-disable-btn').addEventListener('click', async () => {
const pw = el('tfa-disable-pw').value;
const msg = el('tfa-msg');
if (!pw) { msg.textContent = 'Enter your password'; msg.style.color = 'var(--red)'; return; }
if (!pw) { msg.textContent = tr('Enter your password'); msg.style.color = 'var(--red)'; return; }
try {
const r = await fetch('/api/auth/2fa/disable', {
method: 'POST', credentials: 'same-origin',
@ -2221,15 +2224,15 @@ function initAccount() {
});
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
render2FA();
} catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
} catch (e) { msg.textContent = tr(e.message); msg.style.color = 'var(--red)'; }
});
} else {
// 2FA is OFF — show setup button
tfaContent.innerHTML = `
<div style="font-size:12px;opacity:0.6;margin-bottom:8px;">Add an extra layer of security with an authenticator app (Aegis, Google Authenticator, etc.)</div>
<div data-i18n="ui.add.an.extra.layer.of.security.with.an.authenticator.app" style="font-size:12px;opacity:0.6;margin-bottom:8px;">Add an extra layer of security with an authenticator app (Aegis, Google Authenticator, etc.)</div>
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-setup-btn">Set Up 2FA</button>
<button class="admin-btn-add" id="tfa-setup-btn" data-i18n="ui.set.up.2fa">Set Up 2FA</button>
</div>`;
el('tfa-setup-btn').addEventListener('click', async () => {
const msg = el('tfa-msg');
@ -2241,24 +2244,24 @@ function initAccount() {
// Show QR code + manual secret + verify input
tfaContent.innerHTML = `
<div style="text-align:center;margin-bottom:12px;">
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" data-i18n-alt="ui.qr.code" style="border-radius:8px;max-width:200px;">` : ''}
</div>
<div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
<div data-i18n="ui.scan.with.your.authenticator.app.or.enter.manually" style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
Scan with your authenticator app, or enter manually:
</div>
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div>
<input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
<input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" data-i18n-placeholder="ui.enter.6.digit.code.to.verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-cancel-btn" style="opacity:0.5;">Cancel</button>
<button class="admin-btn-add" id="tfa-verify-btn">Verify & Enable</button>
<button class="admin-btn-add" id="tfa-cancel-btn" data-i18n="ui.cancel" style="opacity:0.5;">Cancel</button>
<button class="admin-btn-add" id="tfa-verify-btn" data-i18n="ui.verify.enable">Verify & Enable</button>
</div>`;
el('tfa-verify-code').focus();
el('tfa-cancel-btn').addEventListener('click', () => render2FA());
el('tfa-verify-btn').addEventListener('click', async () => {
const code = el('tfa-verify-code').value.trim();
const vmsg = el('tfa-msg');
if (!code) { vmsg.textContent = 'Enter the code'; vmsg.style.color = 'var(--red)'; return; }
if (!code) { vmsg.textContent = tr('Enter the code'); vmsg.style.color = 'var(--red)'; return; }
try {
const vr = await fetch('/api/auth/2fa/confirm', {
method: 'POST', credentials: 'same-origin',
@ -2270,18 +2273,18 @@ function initAccount() {
// Show backup codes
const codes = result.backup_codes || [];
tfaContent.innerHTML = `
<div style="color:var(--color-save-green, #4caf50);font-size:13px;font-weight:600;margin-bottom:8px;">&#x2713; 2FA Enabled!</div>
<div style="font-size:12px;opacity:0.7;margin-bottom:8px;">Save these backup codes somewhere safe. Each can be used once if you lose your authenticator:</div>
<div data-i18n="ui.x2713.2fa.enabled" style="color:var(--color-save-green, #4caf50);font-size:13px;font-weight:600;margin-bottom:8px;">&#x2713; 2FA Enabled!</div>
<div data-i18n="ui.save.these.backup.codes.somewhere.safe.each.can.be.used" style="font-size:12px;opacity:0.7;margin-bottom:8px;">Save these backup codes somewhere safe. Each can be used once if you lose your authenticator:</div>
<div style="font-family:monospace;font-size:12px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;columns:2;column-gap:16px;margin-bottom:8px;">${codes.map(c => '<div style="margin-bottom:2px;">' + c + '</div>').join('')}</div>
<button class="admin-btn-add" id="tfa-done-btn">Done</button>`;
<button class="admin-btn-add" id="tfa-done-btn" data-i18n="ui.done.e9b450d1">Done</button>`;
el('tfa-done-btn').addEventListener('click', () => render2FA());
} catch (e) { vmsg.textContent = e.message; vmsg.style.color = 'var(--red)'; }
} catch (e) { vmsg.textContent = tr(e.message); vmsg.style.color = 'var(--red)'; }
});
} catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
} catch (e) { msg.textContent = tr(e.message); msg.style.color = 'var(--red)'; }
});
}
} catch (_) {
tfaContent.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load 2FA status</div>';
tfaContent.innerHTML = '<div data-i18n="ui.could.not.load.2fa.status" style="font-size:11px;opacity:0.4;">Could not load 2FA status</div>';
}
}
render2FA();
@ -2297,12 +2300,10 @@ function initAccount() {
// SECURITY: wipe all client-side state on logout so the next user that
// signs in on this browser doesn't inherit the previous account's
// session id, last-used model, draft chat input, or any cached lists.
// Keep "odysseus-last-user" so the login form remembers the username
// (if "Remember me" was on). Without this the chat composer pre-loaded
// the previous user's last model into a fresh session, which read as
// cross-account leakage.
// Keep the remembered username and browser-wide interface language.
// Everything account-owned is still removed.
try {
const _keepKeys = new Set(['odysseus-last-user']);
const _keepKeys = new Set(['odysseus-last-user', 'odysseus.locale']);
const _toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);

View file

@ -575,6 +575,10 @@ export function el(id) {
return document.getElementById(id);
}
function translateDialogText(value) {
return window.odysseusI18n?.translateMessage?.(String(value)) ?? value;
}
/**
* Styled confirm dialog replaces native browser confirm().
* Returns a Promise<boolean|'alternate'>. Existing two-button callers only
@ -613,11 +617,11 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
okBtn.parentNode.insertBefore(altBtn, okBtn);
}
if (titleEl) titleEl.textContent = title || 'Confirm';
msgEl.textContent = message;
okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
altBtn.textContent = alternateText || '';
if (titleEl) titleEl.textContent = translateDialogText(title || 'Confirm');
msgEl.textContent = translateDialogText(message);
okBtn.textContent = translateDialogText(confirmText);
cancelBtn.textContent = translateDialogText(cancelText);
altBtn.textContent = alternateText ? translateDialogText(alternateText) : '';
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
altBtn.className = 'confirm-btn confirm-btn-secondary';
@ -713,14 +717,14 @@ export function styledPrompt(message, {
const okBtn = document.getElementById('styled-prompt-ok');
const cancelBtn = document.getElementById('styled-prompt-cancel');
titleEl.textContent = title;
msgEl.textContent = message || '';
titleEl.textContent = translateDialogText(title);
msgEl.textContent = message ? translateDialogText(message) : '';
msgEl.style.display = message ? '' : 'none';
input.value = defaultValue || '';
input.placeholder = placeholder || '';
input.placeholder = placeholder ? translateDialogText(placeholder) : '';
input.maxLength = maxLength;
okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
okBtn.textContent = translateDialogText(confirmText);
cancelBtn.textContent = translateDialogText(cancelText);
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;

View file

@ -87,6 +87,8 @@
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Noto Sans Arabic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/NotoSansArabic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Noto Sans Arabic'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/NotoSansArabic-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
@ -146,6 +148,14 @@
background-clip: text;
}
.setup-note { color: color-mix(in srgb, var(--fg) 60%, transparent); font-size: 0.8rem; margin-bottom: 1rem; text-align: center; }
.language-row { display: flex; justify-content: flex-end; margin: -0.5rem 0 1rem; }
.language-row label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.language-row select {
max-width: 100%; padding: 0.35rem 0.5rem; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg);
font: 0.75rem 'Fira Code', monospace;
}
.toggle span { margin-inline-end: 0.25em; }
label { display: block; font-size: 0.85rem; margin-bottom: 0.3rem; color: color-mix(in srgb, var(--fg) 65%, transparent); }
input:not(.remember-check) {
width: 100%; padding: 0.6rem 0.8rem; margin-bottom: 1rem;
@ -162,7 +172,7 @@
input:not(.remember-check) { font-size: 16px !important; }
}
/* Clear, visible focus ring for keyboard users on every focusable control. */
input:focus-visible, a:focus-visible, button:focus-visible {
input:focus-visible, select:focus-visible, a:focus-visible, button:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
@ -248,6 +258,35 @@
vertical-align: -3px;
}
@keyframes login-spin { to { transform: rotate(360deg); } }
/* RTL overrides for Arabic and other RTL locales */
:root[dir="rtl"] body {
font-family: 'Noto Sans Arabic', 'Segoe UI', 'Tahoma', 'Geeza Pro', 'Arial', 'Fira Code', monospace;
}
:root[dir="rtl"] .pw-wrapper input:not(.remember-check) {
padding-right: 12px;
padding-left: 2.5rem;
}
:root[dir="rtl"] .pw-toggle {
right: auto;
left: 8px;
}
:root[dir="rtl"] .logo-boat {
margin-right: 0;
margin-left: 0.4rem;
}
:root[dir="rtl"] .version-label {
right: auto;
left: 16px;
}
:root[dir="rtl"] .language-row {
justify-content: flex-start;
}
:root[dir="rtl"] .login-form label,
:root[dir="rtl"] .login-form input,
:root[dir="rtl"] .login-form button {
text-align: right;
}
</style>
</head>
<body>
@ -256,43 +295,47 @@
<svg class="logo-boat" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
</h1>
<p class="setup-note" id="setupNote" style="display:none"></p>
<div class="language-row">
<label for="login-interface-language" data-i18n="ui.language">Language</label>
<select id="login-interface-language" data-language-select aria-label="Language" data-i18n-aria-label="ui.language"></select>
</div>
<div class="error" id="error" role="alert" aria-live="assertive"></div>
<form id="authForm" autocomplete="on">
<label for="username">Username</label>
<label for="username" data-i18n="ui.username">Username</label>
<div class="pw-wrapper">
<input id="username" name="username" type="text" required autofocus autocomplete="username">
<label class="remember-toggle" id="rememberToggle" title="Remember me">
<input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me">
<input id="username" name="username" type="text" required autofocus autocomplete="username" dir="auto">
<label class="remember-toggle" id="rememberToggle" title="Remember me" data-i18n-title="ui.remember.me">
<input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me" data-i18n-aria-label="ui.remember.me">
<span class="remember-dot" aria-hidden="true"></span>
</label>
</div>
<label for="password">Password</label>
<label for="password" data-i18n="ui.password">Password</label>
<div class="pw-wrapper">
<input id="password" name="password" type="password" required autocomplete="current-password">
<button type="button" class="pw-toggle" id="pwToggle" tabindex="-1" aria-label="Show password">
<input id="password" name="password" type="password" required autocomplete="current-password" dir="auto">
<button type="button" class="pw-toggle" id="pwToggle" tabindex="-1" aria-label="Show password" data-i18n-aria-label="ui.show.password">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
</button>
</div>
<div id="confirmGroup" style="display:none">
<label for="confirmPassword">Confirm Password</label>
<label for="confirmPassword" data-i18n="ui.confirm.password">Confirm Password</label>
<div class="pw-wrapper">
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password">
<button type="button" class="pw-toggle" id="pwToggleConfirm" tabindex="-1" aria-label="Show password">
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" dir="auto">
<button type="button" class="pw-toggle" id="pwToggleConfirm" tabindex="-1" aria-label="Show password" data-i18n-aria-label="ui.show.password">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
</button>
</div>
</div>
<button type="submit" id="submitBtn">Sign In</button>
<button type="submit" id="submitBtn" data-i18n="ui.sign.in">Sign In</button>
</form>
<div class="toggle" id="toggleArea" style="display:none">
<span id="toggleText">Don't have an account? </span>
<a id="toggleLink" href="#">Sign up</a>
<span id="toggleText" data-i18n="ui.don.t.have.an.account">Don't have an account?</span>
<a id="toggleLink" href="#" data-i18n="ui.sign.up">Sign up</a>
</div>
</main>
@ -332,31 +375,77 @@
const rememberToggle = document.getElementById('rememberToggle');
function setI18nText(element, key, fallback, parameters = {}) {
if (!element) return;
element.setAttribute('data-i18n', key);
for (const attribute of [...element.attributes]) {
if (attribute.name.startsWith('data-i18n-param-')) element.removeAttribute(attribute.name);
}
for (const [name, value] of Object.entries(parameters)) {
element.setAttribute(`data-i18n-param-${name}`, String(value));
}
element.textContent = fallback;
window.odysseusI18n?.ready?.then(() => {
if (element.getAttribute('data-i18n') === key) {
element.textContent = window.odysseusI18n.t(key, parameters);
}
});
}
function setAuthError(message) {
const exact = {
'Too many requests — try again later': 'auth.too_many_requests',
'Already configured': 'auth.already_configured',
'Username is required': 'auth.username_required',
'Username is reserved': 'auth.username_reserved',
'This username is reserved': 'auth.username_reserved',
'Setup failed': 'auth.setup_failed',
'Run setup first': 'auth.run_setup_first',
'Registration is disabled. Ask an admin for an account.': 'auth.registration_disabled',
'Username already taken': 'auth.username_taken',
'Invalid credentials': 'auth.invalid_credentials',
'Invalid 2FA code': 'auth.invalid_two_factor_code',
'Invalid code': 'auth.invalid_code',
'Login failed': 'auth.login_failed',
'Account creation failed': 'auth.account_creation_failed',
};
const minimum = String(message).match(/^Password must be at least (\d+) characters$/u);
if (minimum) {
setI18nText(errEl, 'auth.password_minimum', message, { 0: minimum[1] });
} else if (Object.hasOwn(exact, message)) {
setI18nText(errEl, exact[message], message);
} else {
errEl.removeAttribute('data-i18n');
errEl.textContent = message;
}
errEl.style.display = 'block';
}
function setMode(m) {
mode = m;
errEl.style.display = 'none';
if (m === 'setup') {
setupNote.textContent = 'First-time setup — create your admin account';
setI18nText(setupNote, 'auth.first_time_setup', 'First-time setup — create your admin account');
setupNote.style.display = 'block';
confirmGroup.style.display = 'block';
submitBtn.textContent = 'Create Admin Account';
setI18nText(submitBtn, 'auth.create_admin_account', 'Create Admin Account');
toggleArea.style.display = 'none';
rememberToggle.style.display = 'none';
} else if (m === 'signup') {
setupNote.style.display = 'none';
confirmGroup.style.display = 'block';
submitBtn.innerHTML = '<span style="position:relative;top:1px;">Create Account</span>';
setI18nText(submitBtn, 'auth.create_account', 'Create Account');
toggleArea.style.display = 'block';
toggleText.textContent = 'Already have an account? ';
toggleLink.textContent = 'Sign in';
setI18nText(toggleText, 'auth.already_have_account', 'Already have an account?');
setI18nText(toggleLink, 'ui.sign.in', 'Sign in');
rememberToggle.style.display = 'none';
} else {
setupNote.style.display = 'none';
confirmGroup.style.display = 'none';
submitBtn.textContent = 'Sign In';
setI18nText(submitBtn, 'ui.sign.in', 'Sign In');
toggleArea.style.display = signupAllowed ? 'block' : 'none';
toggleText.textContent = "Don't have an account? ";
toggleLink.textContent = 'Sign up';
setI18nText(toggleText, 'ui.don.t.have.an.account', "Don't have an account?");
setI18nText(toggleLink, 'ui.sign.up', 'Sign up');
rememberToggle.style.display = '';
}
}
@ -417,8 +506,7 @@
form._totpMode = false;
finishLogin();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
setAuthError(err.message);
submitBtn.disabled = false;
}
return;
@ -428,19 +516,24 @@
if (mode === 'setup' || mode === 'signup') {
const confirm = document.getElementById('confirmPassword').value;
if (password !== confirm) {
errEl.textContent = 'Passwords do not match';
setI18nText(errEl, 'auth.passwords_do_not_match', 'Passwords do not match');
errEl.style.display = 'block';
submitBtn.disabled = false;
return;
}
if (password.length < policy.password_min_length) {
errEl.textContent = `Password must be at least ${policy.password_min_length} characters`;
setI18nText(
errEl,
'auth.password_minimum',
`Password must be at least ${policy.password_min_length} characters`,
{ 0: policy.password_min_length },
);
errEl.style.display = 'block';
submitBtn.disabled = false;
return;
}
if (policy.reserved_usernames.includes(username.toLowerCase())) {
errEl.textContent = 'This username is reserved';
setI18nText(errEl, 'auth.username_reserved', 'This username is reserved');
errEl.style.display = 'block';
submitBtn.disabled = false;
return;
@ -460,8 +553,7 @@
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Account creation failed');
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
setAuthError(err.message);
submitBtn.disabled = false;
return;
}
@ -509,18 +601,17 @@
form._totpMode = true;
const totpWrap = document.createElement('div');
totpWrap.style.cssText = 'margin-top:12px;';
totpWrap.innerHTML = '<label for="totp-input" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" aria-label="Two-factor authentication code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
totpWrap.innerHTML = '<label for="totp-input" data-i18n="auth.two_factor_code" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" data-i18n-placeholder="auth.two_factor_placeholder" aria-label="Two-factor authentication code" data-i18n-aria-label="auth.two_factor_aria" autocomplete="one-time-code" inputmode="numeric" maxlength="8" dir="auto" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
const formEl = submitBtn.parentElement;
formEl.insertBefore(totpWrap, submitBtn);
const totpInput = document.getElementById('totp-input');
totpInput.focus();
submitBtn.textContent = 'Verify';
setI18nText(submitBtn, 'auth.verify', 'Verify');
return;
}
finishLogin();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
setAuthError(err.message);
submitBtn.disabled = false;
return;
}
@ -539,6 +630,7 @@
const show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
btn.innerHTML = show ? eyeOpen : eyeClosed;
btn.setAttribute('data-i18n-aria-label', show ? 'auth.hide_password' : 'ui.show.password');
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
inp.focus();
});
@ -574,6 +666,7 @@ if (window.visualViewport) {
});
}
</script>
<script type="module" src="/static/js/i18n.js"></script>
<script type="module" nonce="{{CSP_NONCE}}">
// Drive the login page's bg effect off the user's saved theme. The
// sync bootstrap above already set the body class + effect CSS vars so

32
static/manifest.ar.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "دردشة AI ذاتية الاستضافة مع الذاكرة والمستندات والأدوات",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ar"
}

32
static/manifest.bg.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Самостоятелно хостван AI чат с памет, документи и инструменти",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "bg"
}

32
static/manifest.cs.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Samoobslužný chat AI s pamětí, dokumenty a nástroji",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "cs"
}

32
static/manifest.da.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Selvvært AI chat med hukommelse, dokumenter og værktøjer",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "da"
}

32
static/manifest.de.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Selbstgehosteter AI-Chat mit Speicher, Dokumenten und Tools",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "de"
}

32
static/manifest.el.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Αυτοφιλοξενούμενη συνομιλία AI με μνήμη, έγγραφα και εργαλεία",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "el"
}

32
static/manifest.en.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Self-hosted AI chat with memory, documents, and tools",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "en"
}

View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Chat AI autohospedado con memoria, documentos y herramientas",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "es-419"
}

32
static/manifest.es.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Chat AI autohospedado con memoria, documentos y herramientas",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "es"
}

32
static/manifest.fi.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Itseisännöity AI-keskustelu muistin, asiakirjojen ja työkalujen kanssa",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "fi"
}

32
static/manifest.fr.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Chat AI auto-hébergé avec mémoire, documents et outils",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "fr"
}

32
static/manifest.hu.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Önkiszolgáló AI csevegés memóriával, dokumentumokkal és eszközökkel",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "hu"
}

32
static/manifest.id.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Obrolan AI mandiri dengan memori, dokumen, dan alat",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "id"
}

32
static/manifest.it.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Chat AI self-hosted con memoria, documenti e strumenti",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "it"
}

32
static/manifest.ja.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "メモリ、ドキュメント、ツールを備えたローカルAIチャット",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ja"
}

32
static/manifest.ko.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "메모리, 문서 및 도구를 갖춘 자체 호스팅 AI 챗봇",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ko"
}

32
static/manifest.ms.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Sembang AI yang dihoskan sendiri dengan memori, dokumen dan alatan",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ms"
}

32
static/manifest.nl.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Zelf-gehoste AI-chat met geheugen, documenten en tools",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "nl"
}

32
static/manifest.no.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Selvdrevet AI chat med minne, dokumenter og verktøy",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "no"
}

32
static/manifest.pl.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Własny czat AI z pamięcią, dokumentami i narzędziami",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "pl"
}

View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Bate-papo AI auto-hospedado com memória, documentos e ferramentas",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "pt-BR"
}

32
static/manifest.pt.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "AI Chat auto-hospedado com memória, documentos e ferramentas",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "pt"
}

32
static/manifest.ro.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Chat AI auto-găzduit cu memorie, documente și instrumente",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ro"
}

32
static/manifest.ru.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Локальный AI-чат с памятью, документами и инструментами",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "ru"
}

32
static/manifest.sv.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Egenhostad AI-chatt med minne, dokument och verktyg",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "sv"
}

32
static/manifest.th.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "แชท AI ที่โฮสต์ด้วยตนเองพร้อมหน่วยความจำ เอกสาร และเครื่องมือ",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "th"
}

32
static/manifest.tr.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Hafıza, belge ve araçlarla kendi kendine barındırılan AI sohbeti",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "tr"
}

32
static/manifest.uk.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "Самостійно розміщений чат із AI, пам’яттю, документами та інструментами",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "uk"
}

32
static/manifest.vi.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "AI chat tự lưu trữ với ký ức, tài liệu và công cụ",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "vi"
}

View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "具有记忆、文档和工具的自托管 AI 聊天机器人",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "zh-CN"
}

View file

@ -0,0 +1,32 @@
{
"name": "Odysseus",
"short_name": "Odysseus",
"description": "自託管 AI 聊天記憶體、文件和工具",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"lang": "zh-TW"
}

View file

@ -118,6 +118,10 @@ body {
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted Arabic support (SIL OFL 1.1). */
@font-face { font-family: 'Noto Sans Arabic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/NotoSansArabic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Noto Sans Arabic'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/NotoSansArabic-SemiBold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 0.95em;
@ -453,7 +457,7 @@ body.bg-pattern-sparkles {
.sidebar {
width: 240px;
background: var(--sidebar-bg, var(--panel));
border-right: 1px solid var(--border);
border-inline-end: 1px solid var(--border);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
display: flex;
flex-direction: column;
@ -470,7 +474,7 @@ body.bg-pattern-sparkles {
.sidebar-resize-handle {
position: absolute;
top: 0;
right: -3px;
inset-inline-end: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
@ -483,8 +487,8 @@ body.bg-pattern-sparkles {
opacity: 0.6;
}
.sidebar.right-side .sidebar-resize-handle {
right: auto;
left: -3px;
inset-inline-end: auto;
inset-inline-start: -3px;
}
.sidebar.resizing {
transition: none;
@ -493,8 +497,8 @@ body.bg-pattern-sparkles {
.sidebar.right-side {
order: 2;
margin: 0;
border-right: none;
border-left: 1px solid var(--border);
border-inline-end: none;
border-inline-start: 1px solid var(--border);
}
.sidebar.hidden {
/* !important so it beats the inline width init.js restores from storage
@ -596,7 +600,8 @@ body.bg-pattern-sparkles {
align-items: center;
justify-content: flex-end; /* right-align when sidebar is on left */
gap: 8px;
padding: 15px 10px 0 40px; /* top padding aligns logo with fixed hamburger */
padding-block: 15px 0;
padding-inline: 40px 10px; /* start padding aligns logo with fixed hamburger */
flex-shrink: 0;
min-height: 40px;
border: none !important;
@ -636,7 +641,7 @@ body.bg-pattern-sparkles {
user-select: none;
position: relative;
top: 0;
left: -10px;
inset-inline-start: -10px;
}
.sidebar-sep {
display: none;
@ -657,8 +662,7 @@ body.bg-pattern-sparkles {
}
.sidebar.right-side .sidebar-header {
justify-content: flex-start;
padding-left: 10px;
padding-right: 40px;
padding-inline: 10px 40px;
}
.sidebar.right-side .sidebar-inner {
padding: 8px;
@ -668,14 +672,14 @@ body.bg-pattern-sparkles {
padding: 2px 30px 4px 4px;
}
.sidebar.right-side .sidebar-brand-title {
margin-left: 10px;
margin-inline-start: 10px;
}
/* Fixed hamburger — always visible, toggles sidebar */
.hamburger-btn {
position: fixed;
top: 12px;
left: 9px;
right: auto;
inset-inline-start: 9px;
inset-inline-end: auto;
z-index: 210;
width: 30px;
height: 30px;
@ -693,8 +697,8 @@ body.bg-pattern-sparkles {
display: flex;
}
body.hamburger-right .hamburger-btn {
left: auto;
right: 9px;
inset-inline-start: auto;
inset-inline-end: 9px;
}
.mobile-new-chat-btn {
display: none;
@ -711,7 +715,7 @@ body.bg-pattern-sparkles {
width: 48px;
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
border-inline-end: 1px solid var(--border);
display: none;
flex-direction: column;
align-items: center;
@ -744,7 +748,7 @@ body.bg-pattern-sparkles {
.rail-resize-handle {
position: absolute;
top: 0;
right: -3px;
inset-inline-end: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
@ -757,14 +761,14 @@ body.bg-pattern-sparkles {
opacity: 0.6;
}
.icon-rail.right-side .rail-resize-handle {
right: auto;
left: -3px;
inset-inline-end: auto;
inset-inline-start: -3px;
}
.icon-rail.right-side {
order: 2;
margin: 0;
border-right: none;
border-left: 1px solid var(--border);
border-inline-end: none;
border-inline-start: 1px solid var(--border);
}
.icon-rail-divider {
width: 24px;
@ -791,7 +795,7 @@ body.bg-pattern-sparkles {
.rail-notes-badge {
position: absolute;
top: 1px;
right: 1px;
inset-inline-end: 1px;
min-width: 14px;
height: 14px;
padding: 0 3px;
@ -823,9 +827,9 @@ body.bg-pattern-sparkles {
background: var(--red);
/* Match the Deep Research badge: right-aligned (auto) with the same
4px left nudge, so both sidebar buttons' dots line up identically. */
margin-left: auto;
margin-inline-start: auto;
position: relative;
left: -4px;
inset-inline-start: -4px;
flex-shrink: 0;
align-self: center;
animation: rail-notes-pulse 1.6s ease-in-out infinite;
@ -4237,7 +4241,7 @@ body.bg-pattern-sparkles {
}
/* Slim text-button variant: swap "Copy" "✓ Copied" via the
::before content while still inheriting the green flash + pulse. */
pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; }
pre.pre-compact .copy-code.copied::before { content: var(--i18n-css-copied, '✓ Copied'); }
/* Edit code button — positioned left of copy button */
pre .edit-code {
@ -4305,7 +4309,7 @@ body.bg-pattern-sparkles {
}
pre.editing code.editing { outline:none; cursor:text; }
pre.editing::before {
content: 'EDITING';
content: var(--i18n-css-editing, 'EDITING');
position: absolute; top: 0; left: 0;
padding: 2px 8px;
font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
@ -4352,8 +4356,8 @@ body.bg-pattern-sparkles {
gap: 0;
}
pre.pre-compact .copy-code svg { display: none; }
pre.pre-compact .copy-code::before { content: 'Copy'; }
pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; }
pre.pre-compact .copy-code::before { content: var(--i18n-css-copy, 'Copy'); }
pre.pre-compact .copy-code.copied::before { content: var(--i18n-css-copied, '✓ Copied'); }
/* Edit: icon + "Edit" label, swap to "Save" when editing */
pre.pre-compact .edit-code {
width: auto;
@ -4362,8 +4366,8 @@ body.bg-pattern-sparkles {
right: 64px;
}
pre.pre-compact .edit-code svg { width: 12px; height: 12px; }
pre.pre-compact .edit-code::after { content: 'Edit'; }
pre.pre-compact .edit-code.active::after { content: 'Save'; }
pre.pre-compact .edit-code::after { content: var(--i18n-css-edit, 'Edit'); }
pre.pre-compact .edit-code.active::after { content: var(--i18n-css-save, 'Save'); }
/* Run: icon + "Run" label */
pre.pre-compact .run-code {
width: auto;
@ -4372,7 +4376,7 @@ body.bg-pattern-sparkles {
right: 126px;
}
pre.pre-compact .run-code svg { width: 12px; height: 12px; }
pre.pre-compact .run-code::after { content: 'Run'; }
pre.pre-compact .run-code::after { content: var(--i18n-css-run, 'Run'); }
/* Bottom-positioned slim buttons (when pre is near the top of the
viewport, the existing JS toggles .bottom to flip them down). */
pre.pre-compact .copy-code.bottom,
@ -4639,7 +4643,7 @@ body.bg-pattern-sparkles {
/* Sidebar overlays chat on mobile */
.sidebar {
position: fixed !important;
top: 0; bottom: 0; left: 0;
top: 0; bottom: 0; inset-inline-start: 0;
z-index: 400;
width: 80% !important;
max-width: 340px;
@ -4664,9 +4668,20 @@ body.bg-pattern-sparkles {
transform: translateX(100%);
}
.sidebar.right-side {
left: auto; right: 0;
inset-inline-start: auto; inset-inline-end: 0;
box-shadow: -4px 0 20px rgba(0,0,0,0.5);
}
:root[dir="rtl"] .sidebar.hidden,
:root[dir="rtl"].ody-sidebar-off .sidebar,
:root[dir="rtl"].ody-mobile-startup-sidebar-hidden .sidebar {
transform: translateX(100%) !important;
}
:root[dir="rtl"] .sidebar.right-side.hidden,
:root[dir="rtl"].ody-sidebar-off .sidebar.right-side,
:root[dir="rtl"].ody-mobile-startup-sidebar-hidden .sidebar.right-side {
transform: translateX(-100%) !important;
}
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
transform: translateX(-100%) !important;
@ -5461,6 +5476,73 @@ body.bg-pattern-sparkles {
margin: -10px -12px;
}
}
/* ── Full RTL layout support ── */
:root[dir="rtl"] body {
font-family: 'Noto Sans Arabic', 'Segoe UI', 'Tahoma', 'Geeza Pro', 'Arial', var(--font-family, 'Fira Code', monospace);
}
/* Settings sidebar: flip border + text alignment */
:root[dir="rtl"] .settings-sidebar {
border-right: none;
border-left: 1px solid var(--border);
}
:root[dir="rtl"] .settings-nav-item {
text-align: right;
justify-content: flex-start;
}
:root[dir="rtl"] .settings-sidebar-label {
text-align: right;
}
/* Main sidebar: flip border + logo alignment */
:root[dir="rtl"] .sidebar {
border-right: none;
border-left: 1px solid var(--border);
}
:root[dir="rtl"] .sidebar.right-side {
border-left: none;
border-right: 1px solid var(--border);
}
:root[dir="rtl"] .sidebar-header {
justify-content: flex-start;
}
/* Chat chrome follows the interface direction; message bodies keep dir=auto. */
:root[dir="rtl"] .chat-history {
direction: rtl;
}
:root[dir="rtl"] .chat-input-wrap {
direction: rtl;
}
/* Login page: center is fine, but flip form internals */
:root[dir="rtl"] .login-form,
:root[dir="rtl"] .login-form input,
:root[dir="rtl"] .login-form button {
direction: rtl;
text-align: right;
}
:root[dir="rtl"] .sidebar-inner,
:root[dir="rtl"] .sidebar-user-bar {
direction: rtl;
}
@media (max-width: 600px) {
:root[dir="rtl"] .settings-sidebar {
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
}
}
@container settings-modal (max-width: 620px) {
:root[dir="rtl"] .settings-sidebar {
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
}
}
/* Flip inline margin-left:auto → margin-right:auto in RTL */
:root[dir="rtl"] [style*="margin-left:auto"],
:root[dir="rtl"] [style*="margin-left: auto"] {
margin-left: 0 !important;
margin-right: auto !important;
}
#mobile-backdrop, #mobile-menu-btn { display:none !important; }
#sidebar-backdrop { display:none !important; }
/* ----- Loading spinner ----- */
@ -11409,10 +11491,10 @@ textarea.memory-add-input {
color: color-mix(in srgb, var(--fg) 65%, transparent);
}
.admin-card > div:has(.admin-switch input) .admin-toggle-state::before {
content: "Disabled";
content: var(--i18n-css-disabled, "Disabled");
}
.admin-card > div:has(.admin-switch input:checked) .admin-toggle-state::before {
content: "Enabled";
content: var(--i18n-css-enabled, "Enabled");
}
/* Nudge the bulk-bar action buttons up 2px (and Memory's -2px left) to
align with the row baseline. Covers both the Memory bulk bar
@ -16702,7 +16784,7 @@ body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane {
background: color-mix(in srgb, var(--accent, #2563eb) 6%, transparent);
}
.doc-editor-pane.email-dragover::after {
content: 'Drop to attach';
content: var(--i18n-css-drop-to-attach, 'Drop to attach');
position: absolute;
top: 50%;
left: 50%;
@ -23654,7 +23736,9 @@ body.gallery-selecting .gallery-dl-btn,
}
.settings-sidebar {
width: 160px;
width: fit-content;
min-width: 160px;
max-width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 8px;
@ -23662,6 +23746,7 @@ body.gallery-selecting .gallery-dl-btn,
flex-direction: column;
gap: 2px;
background: color-mix(in srgb, var(--fg) 2%, transparent);
overflow: hidden;
}
.settings-sidebar-divider { height: 1px; background: var(--border); margin: 8px 12px; }
@ -23689,6 +23774,8 @@ body.gallery-selecting .gallery-dl-btn,
transition: all 0.1s;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-nav-item:hover {
@ -24546,8 +24633,8 @@ a.chat-link[href^="#research-"] {
color: color-mix(in srgb, var(--accent, var(--fg)) 80%, transparent);
opacity: 0.8;
}
.task-log-row:not(.expanded) .task-log-row-toggle::before { content: 'Show more'; }
.task-log-row.expanded .task-log-row-toggle::before { content: 'Show less'; }
.task-log-row:not(.expanded) .task-log-row-toggle::before { content: var(--i18n-css-show-more, 'Show more'); }
.task-log-row.expanded .task-log-row-toggle::before { content: var(--i18n-css-show-less, 'Show less'); }
.task-log-row-toggle:hover::before { opacity: 1; }
.task-log-prompt {
margin-top: 6px;
@ -32747,7 +32834,7 @@ body.doc-find-active mark.doc-find-mark.current {
word-wrap: break-word;
}
.doc-email-richbody:empty::before {
content: "Write your email\2026";
content: var(--i18n-css-write-email, "Write your email…");
opacity: 0.4;
pointer-events: none;
}
@ -34346,7 +34433,7 @@ body.notes-drag-mode .note-card-pin svg {
transition: background 0.28s ease, border-color 0.28s ease;
}
.notes-pane-archive .notes-pane-title::after {
content: 'Archive';
content: var(--i18n-css-archive, 'Archive');
margin-left: 8px;
padding: 2px 8px 2px 22px;
font-size: 9px;
@ -36099,7 +36186,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
pointer-events: none;
}
.note-form-goal-fresh.building::after {
content: 'AI is planning your goal…';
content: var(--i18n-css-planning-goal, 'AI is planning your goal…');
display: block;
text-align: center;
font-size: 12px;
@ -36258,7 +36345,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
}
.note-card-title:hover { opacity: 0.7; }
.note-card-title.empty::before {
content: 'No title';
content: var(--i18n-css-no-title, 'No title');
font-weight: 500;
opacity: 0.3;
font-style: italic;
@ -36445,7 +36532,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
max-height: none;
}
.note-checklist-preview:empty::before {
content: 'No todos';
content: var(--i18n-css-no-todos, 'No todos');
font-size: 10px;
opacity: 0.3;
font-style: italic;

View file

@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
const CACHE_NAME = 'odysseus-v377-i18n';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
@ -16,6 +16,10 @@ const PRECACHE = [
'/',
'/static/style.css',
'/static/app.js',
'/static/js/i18n.js',
'/static/i18n/registry.json',
'/static/i18n/en.json',
'/static/manifest.en.json',
'/static/js/storage.js',
'/static/js/ui.js',
'/static/js/markdown.js',

View file

@ -0,0 +1,897 @@
#!/usr/bin/env node
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-i18n-chrome-'));
const screenshotDir = process.env.I18N_SCREENSHOT_DIR
? path.resolve(process.env.I18N_SCREENSHOT_DIR)
: '';
let browser;
let server;
let configured = true;
const appRoutes = new Set([
'/', '/calendar', '/cookbook', '/email', '/gallery', '/library', '/memory',
'/notes', '/tasks',
]);
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.woff2': 'font/woff2',
};
function json(response, value) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(value));
}
function startServer() {
server = http.createServer((request, response) => {
const url = new URL(request.url, 'http://localhost');
if (appRoutes.has(url.pathname) || url.pathname === '/login') {
const page = url.pathname === '/login' ? 'login.html' : 'index.html';
let html = fs.readFileSync(path.join(ROOT, 'static', page), 'utf8')
.replaceAll('{{CSP_NONCE}}', 'acceptance');
if (page === 'login.html') {
html = html.replace(
'</body>',
'<div hidden id="i18n-static-overwrite-probe">Delete this note?</div></body>',
);
}
response.writeHead(200, { 'content-type': contentTypes['.html'] });
response.end(html);
return;
}
if (url.pathname === '/api/version') return json(response, { version: 'test' });
if (url.pathname === '/__test/configured') {
configured = url.searchParams.get('value') !== 'false';
return json(response, { configured });
}
if (url.pathname === '/api/auth/status') {
return json(response, { authenticated: false, configured, signup_enabled: true });
}
if (url.pathname === '/api/auth/policy') {
return json(response, { password_min_length: 8, reserved_usernames: [] });
}
if (url.pathname === '/api/auth/2fa/status') {
return json(response, { enabled: false });
}
if (url.pathname === '/api/auth/login' && request.method === 'POST') {
return json(response, { requires_totp: true });
}
const relative = decodeURIComponent(url.pathname).replace(/^\/+/, '');
const file = path.resolve(ROOT, relative);
if (!file.startsWith(`${ROOT}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404);
response.end('not found');
return;
}
response.writeHead(200, {
'cache-control': 'no-store',
'content-type': contentTypes[path.extname(file)] || 'application/octet-stream',
});
fs.createReadStream(file).pipe(response);
});
return new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
}
function startBrowser() {
return new Promise((resolve, reject) => {
browser = spawn(process.env.CHROMIUM || 'chromium', [
'--headless',
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--lang=en-US',
'--remote-debugging-address=127.0.0.1',
'--remote-debugging-port=0',
`--user-data-dir=${profile}`,
'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
let output = '';
const timeout = setTimeout(() => reject(new Error(`Chromium did not start: ${output}`)), 30_000);
browser.stderr.setEncoding('utf8');
browser.stderr.on('data', chunk => {
output += chunk;
const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/);
if (!match) return;
clearTimeout(timeout);
resolve(new URL(match[1]).port);
});
browser.once('error', error => {
clearTimeout(timeout);
reject(error);
});
browser.once('exit', code => {
if (!output.includes('DevTools listening')) {
clearTimeout(timeout);
reject(new Error(`Chromium exited before DevTools was ready (${code})`));
}
});
});
}
async function connect(debuggerUrl) {
const socket = new WebSocket(debuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', reject, { once: true });
});
let sequence = 0;
const pending = new Map();
const eventWaiters = new Map();
socket.addEventListener('message', event => {
const message = JSON.parse(event.data);
if (message.method && eventWaiters.has(message.method)) {
const waiters = eventWaiters.get(message.method);
eventWaiters.delete(message.method);
waiters.forEach(resolve => resolve(message.params));
}
if (!message.id || !pending.has(message.id)) return;
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(new Error(message.error.message));
else resolve(message.result);
});
return {
close: () => socket.close(),
waitFor(method, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`timed out waiting for ${method}`)), timeoutMs);
const done = value => {
clearTimeout(timeout);
resolve(value);
};
const waiters = eventWaiters.get(method) || [];
waiters.push(done);
eventWaiters.set(method, waiters);
});
},
send(method, params = {}) {
const id = ++sequence;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
},
};
}
function valueFrom(result) {
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description || 'browser evaluation failed');
}
return result.result.value;
}
async function captureScreenshot(cdp, name) {
if (!screenshotDir) return;
fs.mkdirSync(screenshotDir, { recursive: true });
const { data } = await cdp.send('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: false,
});
fs.writeFileSync(path.join(screenshotDir, name), Buffer.from(data, 'base64'));
}
async function main() {
await startServer();
const webPort = server.address().port;
const devtoolsPort = await startBrowser();
const pageUrl = `http://127.0.0.1:${webPort}/login`;
const page = await fetch(
`http://127.0.0.1:${devtoolsPort}/json/new?${encodeURIComponent('about:blank')}`,
{ method: 'PUT' },
).then(response => response.json());
const cdp = await connect(page.webSocketDebuggerUrl);
try {
await cdp.send('Page.enable');
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 1440,
height: 900,
deviceScaleFactor: 1,
mobile: false,
});
const loaded = cdp.waitFor('Page.loadEventFired');
await cdp.send('Page.navigate', { url: pageUrl });
await loaded;
const evaluation = await cdp.send('Runtime.evaluate', {
awaitPromise: true,
returnByValue: true,
expression: `(async () => {
for (let attempt = 0; attempt < 200 && !window.odysseusI18n; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
if (!window.odysseusI18n) throw new Error('i18n runtime did not initialize');
await window.odysseusI18n.ready;
const i18nResources = () => performance.getEntriesByType('resource')
.filter(entry => new URL(entry.name).pathname.startsWith('/static/i18n/'));
const initialFiles = [...new Set(i18nResources().map(
entry => new URL(entry.name).pathname.split('/').pop(),
))].sort();
const nativeFetch = window.fetch;
const fallbackFetch = { calls: 0, removed: false };
window.fetch = async (input, options) => {
const response = await nativeFetch(input, options);
if (!String(input).endsWith('/fr.json')) return response;
fallbackFetch.calls += 1;
const incomplete = await response.json();
fallbackFetch.removed = delete incomplete['ui.copy.all.items'];
return new Response(JSON.stringify(incomplete), {
status: 200,
headers: { 'content-type': 'application/json' },
});
};
await window.odysseusI18n.setLocale('fr', {
persist: false,
announce: false,
});
window.fetch = nativeFetch;
const englishFallback = {
locale: window.odysseusI18n.locale,
text: window.odysseusI18n.t('ui.copy.all.items'),
fetch: fallbackFetch,
};
await window.odysseusI18n.setLocale('en', {
persist: false,
announce: false,
});
window.fetch = (input, options) => (
String(input).endsWith('/de.json')
? Promise.reject(new TypeError('synthetic locale load failure'))
: nativeFetch(input, options)
);
let loadErrorMessage = '';
try {
await window.odysseusI18n.setLocale('de', {
persist: false,
announce: false,
});
} catch (error) {
loadErrorMessage = error.message;
}
window.fetch = nativeFetch;
const loadError = {
message: loadErrorMessage,
locale: window.odysseusI18n.locale,
lang: document.documentElement.lang,
dir: document.documentElement.dir,
};
window.fetch = (input, options) => {
const delay = String(input).endsWith('/bg.json') ? 120 : 10;
return new Promise((resolve, reject) => {
setTimeout(() => nativeFetch(input, options).then(resolve, reject), delay);
});
};
const firstSwitch = window.odysseusI18n.setLocale('bg', {
persist: false,
announce: false,
});
await new Promise(resolve => setTimeout(resolve, 5));
const latestSwitch = window.odysseusI18n.setLocale('ar', {
persist: false,
announce: false,
});
await Promise.all([firstSwitch, latestSwitch]);
window.fetch = nativeFetch;
const race = {
locale: window.odysseusI18n.locale,
lang: document.documentElement.lang,
dir: document.documentElement.dir,
};
const states = [];
const timings = [];
const allStarted = performance.now();
for (const [id, metadata] of Object.entries(window.odysseusI18n.locales)) {
const started = performance.now();
await window.odysseusI18n.setLocale(id, {
persist: false,
announce: false,
});
timings.push(performance.now() - started);
states.push({
requested: id,
locale: window.odysseusI18n.locale,
lang: document.documentElement.lang,
dir: document.documentElement.dir,
expectedDir: metadata.dir,
selected: document.getElementById('login-interface-language').value,
manifest: document.querySelector('link[rel="manifest"]').getAttribute('href'),
translation: window.odysseusI18n.t('ui.delete.this.note'),
missingKey: window.odysseusI18n.t('__acceptance_missing_key__'),
});
}
const allElapsed = performance.now() - allStarted;
const sortedTimings = [...timings].sort((left, right) => left - right);
const loadedResources = i18nResources();
const allLocales = {
states,
payload: {
initialFiles,
loadedFiles: [...new Set(loadedResources.map(
entry => new URL(entry.name).pathname.split('/').pop(),
))].sort(),
requestCount: loadedResources.length,
decodedBodyBytes: loadedResources.reduce(
(total, entry) => total + entry.decodedBodySize,
0,
),
transferBytes: loadedResources.reduce(
(total, entry) => total + entry.transferSize,
0,
),
},
performance: {
samples: timings.length,
totalMs: Math.round(allElapsed * 100) / 100,
medianMs: Math.round(
sortedTimings[Math.floor(sortedTimings.length / 2)] * 100,
) / 100,
p95Ms: Math.round(
sortedTimings[Math.ceil(sortedTimings.length * 0.95) - 1] * 100,
) / 100,
maxMs: Math.round(sortedTimings.at(-1) * 100) / 100,
},
};
await window.odysseusI18n.setLocale('ar', {
persist: false,
announce: false,
});
await window.odysseusI18n.setLocale('fr', {
persist: false,
announce: false,
});
allLocales.payload.cachedRepeatRequests = (
i18nResources().length - loadedResources.length
);
const uiProbe = document.createElement('div');
uiProbe.id = 'i18n-ui-probe';
uiProbe.setAttribute('data-i18n', 'ui.delete.this.note');
uiProbe.textContent = 'Delete this note?';
document.body.appendChild(uiProbe);
const parameterProbe = document.createElement('div');
parameterProbe.setAttribute('data-i18n', 'ui.add.value');
parameterProbe.setAttribute('data-i18n-param-0', '8');
parameterProbe.textContent = 'Add 8';
document.body.appendChild(parameterProbe);
const lateDiv = document.createElement('div');
lateDiv.textContent = 'Delete this note?';
document.body.appendChild(lateDiv);
const lateButton = document.createElement('button');
lateButton.textContent = 'Sign In';
document.body.appendChild(lateButton);
const lateSession = document.createElement('div');
lateSession.className = 'session-title';
lateSession.textContent = 'Delete this note?';
document.body.appendChild(lateSession);
const rerenderProbe = document.createElement('button');
rerenderProbe.setAttribute('data-i18n', 'ui.delete.this.note');
rerenderProbe.textContent = 'Delete this note?';
document.body.appendChild(rerenderProbe);
const userProbe = document.createElement('div');
userProbe.className = 'msg';
userProbe.innerHTML = '<div class="body">Delete this note?</div>';
document.body.appendChild(userProbe);
const directionProbe = document.createElement('textarea');
directionProbe.value = 'مرحبا';
document.body.appendChild(directionProbe);
await new Promise(resolve => setTimeout(resolve, 80));
const staticProbe = document.getElementById('i18n-static-overwrite-probe');
const staticInitial = staticProbe.textContent;
staticProbe.textContent = 'User preferences';
const rerenderInitial = rerenderProbe.textContent;
rerenderProbe.textContent = 'Delete this note?';
await new Promise(resolve => setTimeout(resolve, 40));
const french = {
lang: document.documentElement.lang,
dir: document.documentElement.dir,
username: document.querySelector('label[for="username"]').textContent.trim(),
signIn: document.getElementById('submitBtn').textContent.trim(),
options: document.getElementById('login-interface-language').options.length,
selected: document.getElementById('login-interface-language').value,
manifest: document.querySelector('link[rel="manifest"]').getAttribute('href'),
uiProbe: uiProbe.textContent,
parameterProbe: parameterProbe.textContent,
userProbe: userProbe.querySelector('.body').textContent,
staticInitial,
lateDiv: lateDiv.textContent,
lateButton: lateButton.textContent,
lateSession: lateSession.textContent,
postLoadRerender: {
initial: rerenderInitial,
afterComponentRender: rerenderProbe.textContent,
},
direction: directionProbe.dir,
directionValue: directionProbe.value,
navigatorLocale: navigator.language,
formattedNumber: window.odysseusI18n.formatNumber(1234.5),
frenchNumber: new Intl.NumberFormat('fr').format(1234.5),
navigatorNumber: new Intl.NumberFormat(navigator.language).format(1234.5),
formattedDate: window.odysseusI18n.formatDate(
new Date(Date.UTC(2026, 6, 28)),
{ dateStyle: 'long', timeZone: 'UTC' },
),
frenchDate: new Intl.DateTimeFormat(
'fr',
{ dateStyle: 'long', timeZone: 'UTC' },
).format(new Date(Date.UTC(2026, 6, 28))),
templateInputs: ['My Notes', 'Class', 'User preferences'].map(
value => window.odysseusI18n.translateLegacy(value),
),
dynamicDialog: window.odysseusI18n.translateMessage('Delete "Example"?'),
unknownMessage: window.odysseusI18n.translateMessage('Unknown status'),
};
document.getElementById('toggleLink').click();
await new Promise(resolve => setTimeout(resolve, 50));
const signup = {
submit: document.getElementById('submitBtn').textContent.trim(),
prompt: document.getElementById('toggleText').textContent.trim(),
link: document.getElementById('toggleLink').textContent.trim(),
};
document.getElementById('username').value = 'acceptance-user';
document.getElementById('password').value = 'a';
document.getElementById('confirmPassword').value = 'a';
document.getElementById('authForm').requestSubmit();
await new Promise(resolve => setTimeout(resolve, 80));
signup.passwordMinimum = document.getElementById('error').textContent.trim();
document.getElementById('toggleLink').click();
document.getElementById('authForm').requestSubmit();
await new Promise(resolve => setTimeout(resolve, 100));
const totp = {
label: document.querySelector('label[for="totp-input"]').textContent.trim(),
placeholder: document.getElementById('totp-input').placeholder,
verify: document.getElementById('submitBtn').textContent.trim(),
};
await window.odysseusI18n.setLocale('constructor', {
persist: false,
announce: false,
});
const malicious = {
locale: window.odysseusI18n.locale,
lang: document.documentElement.lang,
inheritedKey: window.odysseusI18n.t('constructor'),
};
await window.odysseusI18n.setLocale('ar', {
persist: false,
announce: false,
});
const arabic = {
lang: document.documentElement.lang,
dir: document.documentElement.dir,
username: document.querySelector('label[for="username"]').textContent.trim(),
};
await window.odysseusI18n.setLocale('fr');
const safety = {
staticOverwrite: staticProbe.textContent,
lateDiv: lateDiv.textContent,
lateButton: lateButton.textContent,
lateSession: lateSession.textContent,
semantic: uiProbe.textContent,
parameter: parameterProbe.textContent,
};
return {
allLocales,
french,
signup,
totp,
englishFallback,
loadError,
malicious,
race,
arabic,
safety,
};
})()`,
});
const result = valueFrom(evaluation);
const expectedCatalogFiles = [
'registry.json',
...result.allLocales.states.map(state => `${state.requested}.json`),
].sort();
if (
result.allLocales.states.length !== 31
|| result.allLocales.states.some(state => (
state.locale !== state.requested
|| state.lang !== state.requested
|| state.dir !== state.expectedDir
|| state.selected !== state.requested
|| !state.manifest.endsWith(`/static/manifest.${state.requested}.json`)
|| !state.translation
|| (state.requested !== 'en' && state.translation === 'Delete this note?')
|| state.missingKey !== '__acceptance_missing_key__'
))
) {
throw new Error(`all-locale runtime switch failed: ${JSON.stringify(result.allLocales)}`);
}
if (
JSON.stringify(result.allLocales.payload.initialFiles)
!== JSON.stringify(['en.json', 'registry.json'])
|| JSON.stringify(result.allLocales.payload.loadedFiles)
!== JSON.stringify(expectedCatalogFiles)
|| result.allLocales.payload.requestCount !== expectedCatalogFiles.length
|| result.allLocales.payload.cachedRepeatRequests !== 0
|| result.allLocales.payload.decodedBodyBytes <= 0
) {
throw new Error(`on-demand catalog payload failed: ${JSON.stringify(result.allLocales)}`);
}
if (
result.allLocales.performance.samples !== 31
|| ![
result.allLocales.performance.totalMs,
result.allLocales.performance.medianMs,
result.allLocales.performance.p95Ms,
result.allLocales.performance.maxMs,
].every(value => Number.isFinite(value) && value >= 0)
) {
throw new Error(`locale switch timing failed: ${JSON.stringify(result.allLocales)}`);
}
if (result.french.lang !== 'fr' || result.french.dir !== 'ltr') {
throw new Error(`French document metadata failed: ${JSON.stringify(result.french)}`);
}
if (result.french.options !== 31 || result.french.selected !== 'fr') {
throw new Error(`language selector failed: ${JSON.stringify(result.french)}`);
}
if (result.french.username === 'Username' || result.french.signIn === 'Sign In') {
throw new Error(`French auth strings were not translated: ${JSON.stringify(result.french)}`);
}
if (result.french.uiProbe === 'Delete this note?') {
throw new Error(`semantic UI text did not translate: ${JSON.stringify(result.french)}`);
}
if (result.french.parameterProbe === 'Add 8' || !result.french.parameterProbe.includes('8')) {
throw new Error(`semantic interpolation failed: ${JSON.stringify(result.french)}`);
}
if (result.french.userProbe !== 'Delete this note?') {
throw new Error(`user content was translated: ${JSON.stringify(result.french)}`);
}
if (
result.french.dynamicDialog === 'Delete "Example"?'
|| !result.french.dynamicDialog.includes('Example')
) {
throw new Error(`dynamic dialog did not translate safely: ${JSON.stringify(result.french)}`);
}
if (result.french.unknownMessage !== 'Unknown status') {
throw new Error(`unknown text was translated: ${JSON.stringify(result.french)}`);
}
if (
result.french.lateDiv !== 'Delete this note?'
|| result.french.lateButton !== 'Sign In'
|| result.french.lateSession !== 'Delete this note?'
) {
throw new Error(`late unmarked content was translated: ${JSON.stringify(result.french)}`);
}
if (
result.french.postLoadRerender.initial === 'Delete this note?'
|| result.french.postLoadRerender.afterComponentRender === 'Delete this note?'
) {
throw new Error(`post-load semantic rerender failed: ${JSON.stringify(result.french)}`);
}
if (
result.french.staticInitial === 'Delete this note?'
|| result.french.direction !== 'auto'
|| result.french.directionValue !== 'مرحبا'
|| result.french.formattedNumber !== result.french.frenchNumber
|| result.french.formattedNumber === result.french.navigatorNumber
|| result.french.formattedDate !== result.french.frenchDate
|| JSON.stringify(result.french.templateInputs)
!== JSON.stringify(['My Notes', 'Class', 'User preferences'])
) {
throw new Error(`static enrollment safety failed: ${JSON.stringify(result.french)}`);
}
if (!result.french.manifest.endsWith('/static/manifest.fr.json')) {
throw new Error(`localized manifest failed: ${JSON.stringify(result.french)}`);
}
if (
result.signup.submit === 'Create Account'
|| result.signup.prompt === 'Already have an account?'
|| result.signup.link.toLowerCase() === 'sign in'
|| result.signup.passwordMinimum === 'Password must be at least 8 characters'
|| !result.signup.passwordMinimum.includes('8')
) {
throw new Error(`dynamic signup localization failed: ${JSON.stringify(result.signup)}`);
}
if (
result.totp.label === '2FA Code'
|| result.totp.placeholder === 'Enter 6-digit code'
|| result.totp.verify === 'Verify'
) {
throw new Error(`dynamic TOTP localization failed: ${JSON.stringify(result.totp)}`);
}
if (
result.englishFallback.locale !== 'fr'
|| result.englishFallback.text !== 'Copy all items'
|| result.englishFallback.fetch.calls !== 1
|| !result.englishFallback.fetch.removed
) {
throw new Error(`English catalog fallback failed: ${JSON.stringify(result.englishFallback)}`);
}
if (
!result.loadError.message.includes('synthetic locale load failure')
|| result.loadError.locale !== 'en'
|| result.loadError.lang !== 'en'
|| result.loadError.dir !== 'ltr'
) {
throw new Error(`failed catalog load changed locale: ${JSON.stringify(result.loadError)}`);
}
if (
result.malicious.locale !== 'en'
|| result.malicious.lang !== 'en'
|| result.malicious.inheritedKey !== 'constructor'
) {
throw new Error(`prototype locale/key fallback failed: ${JSON.stringify(result.malicious)}`);
}
if (
result.race.locale !== 'ar'
|| result.race.lang !== 'ar'
|| result.race.dir !== 'rtl'
) {
throw new Error(`concurrent locale switch race failed: ${JSON.stringify(result.race)}`);
}
if (
result.arabic.lang !== 'ar'
|| result.arabic.dir !== 'rtl'
|| result.arabic.username === 'Username'
) {
throw new Error(`Arabic/RTL switch failed: ${JSON.stringify(result.arabic)}`);
}
if (
result.safety.staticOverwrite !== 'User preferences'
|| result.safety.lateDiv !== 'Delete this note?'
|| result.safety.lateButton !== 'Sign In'
|| result.safety.lateSession !== 'Delete this note?'
|| result.safety.semantic === 'Delete this note?'
|| result.safety.parameter === 'Add 8'
) {
throw new Error(`locale-switch enrollment safety failed: ${JSON.stringify(result.safety)}`);
}
if (screenshotDir) {
const screenshotLoaded = cdp.waitFor('Page.loadEventFired');
await cdp.send('Page.navigate', { url: pageUrl });
await screenshotLoaded;
await cdp.send('Runtime.evaluate', {
awaitPromise: true,
expression: `(async () => {
await window.odysseusI18n.ready;
await window.odysseusI18n.setLocale('fr', { persist: false, announce: false });
})()`,
});
await captureScreenshot(cdp, 'i18n-login-fr.png');
await cdp.send('Runtime.evaluate', {
awaitPromise: true,
expression: `window.odysseusI18n.setLocale(
'ar', { persist: false, announce: false }
)`,
});
await captureScreenshot(cdp, 'i18n-login-ar.png');
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 390,
height: 844,
deviceScaleFactor: 1,
mobile: true,
});
await captureScreenshot(cdp, 'i18n-login-ar-mobile.png');
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 1440,
height: 900,
deviceScaleFactor: 1,
mobile: false,
});
}
await fetch(`http://127.0.0.1:${webPort}/__test/configured?value=false`);
const setupLoaded = cdp.waitFor('Page.loadEventFired');
await cdp.send('Page.navigate', { url: pageUrl });
await setupLoaded;
const setupEvaluation = await cdp.send('Runtime.evaluate', {
awaitPromise: true,
returnByValue: true,
expression: `(async () => {
for (let attempt = 0; attempt < 200 && !window.odysseusI18n; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
if (!window.odysseusI18n) throw new Error('setup i18n runtime did not initialize');
await window.odysseusI18n.ready;
for (let attempt = 0; attempt < 200 && document.getElementById('setupNote').style.display === 'none'; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
return {
lang: document.documentElement.lang,
note: document.getElementById('setupNote').textContent.trim(),
submit: document.getElementById('submitBtn').textContent.trim(),
confirmVisible: document.getElementById('confirmGroup').style.display !== 'none',
};
})()`,
});
result.setup = valueFrom(setupEvaluation);
if (
result.setup.lang !== 'fr'
|| result.setup.note === 'First-time setup — create your admin account'
|| result.setup.submit === 'Create Admin Account'
|| !result.setup.confirmVisible
) {
throw new Error(`first-run setup localization failed: ${JSON.stringify(result.setup)}`);
}
await fetch(`http://127.0.0.1:${webPort}/__test/configured?value=true`);
const indexLoaded = cdp.waitFor('Page.loadEventFired');
await cdp.send('Page.navigate', { url: `http://127.0.0.1:${webPort}/` });
await indexLoaded;
const indexEvaluation = await cdp.send('Runtime.evaluate', {
awaitPromise: true,
returnByValue: true,
expression: `(async () => {
for (let attempt = 0; attempt < 200 && !window.odysseusI18n; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
if (!window.odysseusI18n) throw new Error('index i18n runtime did not initialize');
await window.odysseusI18n.ready;
const settings = (await import('/static/js/settings.js')).default;
settings.open('account');
for (let attempt = 0; attempt < 100 && !document.getElementById('tfa-setup-btn'); attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const twoFactor = document.getElementById('settings-2fa-content').textContent.trim();
document.getElementById('settings-modal').classList.add('hidden');
const french = {
lang: document.documentElement.lang,
dir: document.documentElement.dir,
options: document.getElementById('set-interface-language').options.length,
selected: document.getElementById('set-interface-language').value,
newChat: document.querySelector('#sidebar-new-chat-btn .grow').textContent.trim(),
rearrange: document.getElementById('session-rearrange-toggle').childNodes[0].nodeValue.trim(),
twoFactor,
};
await window.odysseusI18n.setLocale('ar', { persist: false, announce: false });
const sidebar = document.getElementById('sidebar').getBoundingClientRect();
const handle = document.getElementById('sidebar-resize-handle').getBoundingClientRect();
const hamburger = document.getElementById('hamburger-btn').getBoundingClientRect();
const settingsModal = document.getElementById('settings-modal');
settingsModal.classList.remove('hidden');
const settingsSidebar = settingsModal.querySelector('.settings-sidebar').getBoundingClientRect();
const settingsPanels = settingsModal.querySelector('.settings-panels').getBoundingClientRect();
const settingsNav = settingsModal.querySelector('.settings-nav-item');
const settingsNavIcon = settingsNav.querySelector('svg').getBoundingClientRect();
const settingsNavLabel = settingsNav.querySelector('span').getBoundingClientRect();
settingsModal.classList.add('hidden');
const rtl = {
lang: document.documentElement.lang,
dir: document.documentElement.dir,
viewportWidth: window.innerWidth,
sidebarLeft: sidebar.left,
sidebarRight: sidebar.right,
sidebarWidth: sidebar.width,
handleCenter: handle.left + handle.width / 2,
hamburgerLeft: hamburger.left,
hamburgerRight: hamburger.right,
textareaDir: document.getElementById('message').dir,
inputDir: document.getElementById('model-picker-search').dir,
settingsSidebarLeft: settingsSidebar.left,
settingsPanelsRight: settingsPanels.right,
settingsNavIconLeft: settingsNavIcon.left,
settingsNavLabelLeft: settingsNavLabel.left,
};
await window.odysseusI18n.setLocale('fr', { persist: false, announce: false });
return { ...french, rtl };
})()`,
});
result.index = valueFrom(indexEvaluation);
if (
result.index.lang !== 'fr'
|| result.index.dir !== 'ltr'
|| result.index.options !== 31
|| result.index.selected !== 'fr'
|| result.index.newChat === 'New Chat'
|| result.index.rearrange === '↑↓ Rearrange'
|| result.index.twoFactor.includes('Add an extra layer of security')
|| result.index.twoFactor.includes('Set Up 2FA')
) {
throw new Error(`localized app shell failed: ${JSON.stringify(result.index)}`);
}
if (
result.index.rtl.lang !== 'ar'
|| result.index.rtl.dir !== 'rtl'
|| Math.abs(result.index.rtl.viewportWidth - result.index.rtl.sidebarRight) > 2
|| result.index.rtl.sidebarWidth < 100
|| Math.abs(result.index.rtl.handleCenter - result.index.rtl.sidebarLeft) > 2
|| result.index.rtl.hamburgerLeft < result.index.rtl.viewportWidth / 2
|| result.index.rtl.viewportWidth - result.index.rtl.hamburgerRight > 20
|| result.index.rtl.textareaDir !== 'auto'
|| result.index.rtl.inputDir !== 'auto'
|| result.index.rtl.settingsSidebarLeft < result.index.rtl.settingsPanelsRight - 2
|| result.index.rtl.settingsNavIconLeft <= result.index.rtl.settingsNavLabelLeft
) {
throw new Error(`Arabic desktop geometry failed: ${JSON.stringify(result.index.rtl)}`);
}
if (screenshotDir) {
await cdp.send('Runtime.evaluate', {
awaitPromise: true,
expression: `(async () => {
await window.odysseusI18n.setLocale('ar', {
persist: false,
announce: false,
});
const settings = (await import('/static/js/settings.js')).default;
settings.open('appearance');
document.querySelectorAll('.toast').forEach(toast => toast.remove());
})()`,
});
await captureScreenshot(cdp, 'i18n-settings-ar.png');
}
const routeLoaded = cdp.waitFor('Page.loadEventFired');
await cdp.send('Page.navigate', { url: `http://127.0.0.1:${webPort}/calendar` });
await routeLoaded;
const routeEvaluation = await cdp.send('Runtime.evaluate', {
awaitPromise: true,
returnByValue: true,
expression: `(async () => {
for (let attempt = 0; attempt < 200 && !window.odysseusI18n; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 25));
}
await window.odysseusI18n.ready;
const manifestUrl = document.querySelector('link[rel="manifest"]').href;
const manifest = await fetch(manifestUrl).then(response => response.json());
return {
lang: document.documentElement.lang,
title: document.title,
manifestLang: manifest.lang,
shortName: manifest.short_name,
};
})()`,
});
result.route = valueFrom(routeEvaluation);
if (
result.route.lang !== 'fr'
|| result.route.manifestLang !== 'fr'
|| result.route.title === 'Calendar — Odysseus'
|| result.route.shortName === 'Calendar'
) {
throw new Error(`localized route metadata failed: ${JSON.stringify(result.route)}`);
}
process.stdout.write(`${JSON.stringify(result)}\n`);
} finally {
cdp.close();
}
}
try {
await main();
} finally {
if (server) await new Promise(resolve => server.close(resolve));
if (browser && browser.exitCode == null) {
browser.kill('SIGTERM');
await new Promise(resolve => {
const timeout = setTimeout(() => {
if (browser.exitCode == null) browser.kill('SIGKILL');
resolve();
}, 3_000);
browser.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
fs.rmSync(profile, { recursive: true, force: true });
}

View file

@ -0,0 +1,155 @@
import asyncio
import json
import pytest
import src.agent_loop as al
from src.tool_policy import build_effective_tool_policy
def _collect(gen):
async def _run():
return [chunk async for chunk in gen]
return asyncio.run(_run())
def _events(chunks):
events = []
for chunk in chunks:
if not chunk.startswith("data: ") or chunk.startswith("data: [DONE]"):
continue
try:
events.append(json.loads(chunk[6:]))
except json.JSONDecodeError:
pass
return events
def _patch_loop(monkeypatch, response):
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None)
monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set())
async def fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": response})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream)
def _run_loop(
response,
monkeypatch,
*,
relevant_tools=frozenset({"bash"}),
max_rounds=1,
tool_policy=None,
):
_patch_loop(monkeypatch, response)
return _events(
_collect(
al.stream_agent_loop(
"http://local.test/v1",
"local-model",
[{"role": "user", "content": "Inspect the system and act if needed."}],
max_rounds=max_rounds,
relevant_tools=set(relevant_tools),
tool_policy=tool_policy,
)
)
)
@pytest.mark.parametrize(
"response",
[
"Jag ska kontrollera loggarna nu.",
"これからログを確認します。",
"سأتحقق من السجلات الآن.",
"Voy a revisar los registros ahora.",
"我来检查日志。",
],
)
def test_multilingual_pending_uses_local_phrase_table(monkeypatch, response):
assert al._MULTILINGUAL_INTENT_RE.search(response)
events = _run_loop(response, monkeypatch)
assert any(event.get("type") == "agent_step" for event in events)
def test_multilingual_terminal_answer_is_not_nudged(monkeypatch):
response = "ログを確認しました。エラーはありません。"
assert al._MULTILINGUAL_INTENT_RE.search(response) is None
events = _run_loop(response, monkeypatch)
assert not any(event.get("type") == "agent_step" for event in events)
assert not any(event.get("type") == "intent_nudge_exhausted" for event in events)
def test_multilingual_supervisor_never_calls_a_model(monkeypatch):
async def should_not_run(*args, **kwargs):
raise AssertionError("intent supervision must not make a classifier call")
monkeypatch.setattr("src.llm_core.llm_call_async", should_not_run)
events = _run_loop(
"Jag ska kontrollera loggarna nu.",
monkeypatch,
max_rounds=3,
)
assert any(event.get("type") == "agent_step" for event in events)
def test_multilingual_pending_flows_through_existing_nudge_cap(monkeypatch):
events = _run_loop(
"Jag ska kontrollera loggarna nu.",
monkeypatch,
max_rounds=5,
)
guard = next(
event for event in events if event.get("type") == "intent_nudge_exhausted"
)
assert guard["reason"] == "intent_without_action_nudge_cap"
assert guard["nudges"] == 2
def test_english_fast_path_still_nudges_without_multilingual_scan(monkeypatch):
events = _run_loop("Let me check the logs", monkeypatch, max_rounds=3)
assert any(event.get("type") == "agent_step" for event in events)
@pytest.mark.parametrize(
("response", "relevant_tools", "tool_policy"),
[
(
"これからログを確認します。",
{"bash"},
build_effective_tool_policy(last_user_message="Do not use tools."),
),
(
"これからログを確認します。" + ("これは長い説明です。" * 50),
{"bash"},
None,
),
(
"これからログを確認します。\n```text\nexample\n```",
{"bash"},
None,
),
(
"これからログを確認します。",
{"ask_user"},
None,
),
],
)
def test_local_multilingual_detector_skips_non_action_context(
monkeypatch, response, relevant_tools, tool_policy
):
events = _run_loop(
response,
monkeypatch,
relevant_tools=relevant_tools,
tool_policy=tool_policy,
)
assert not any(event.get("type") == "agent_step" for event in events)
assert not any(event.get("type") == "intent_nudge_exhausted" for event in events)

View file

@ -0,0 +1,227 @@
"""Localized IMAP special-use folders stay opaque and role-driven."""
import asyncio
from contextlib import contextmanager
from pathlib import Path
import pytest
pytest.importorskip("mcp")
import mcp_servers.email_server as email_mcp
from routes import email_routes
SENT_MUTF7 = "&BB4EQgQ,BEAEMAQyBDsENQQ9BD0ESwQ1-"
ALL_MUTF7 = "&BBIEMAQ2BD0EPgQ9BD0ESwQ1-"
LIST_LINES = [
br'(\HasNoChildren) "/" "INBOX"',
f'(\\HasNoChildren \\sEnT) "/" "{SENT_MUTF7}"'.encode(),
br'(\aRcHiVe) "/" "Archiv"',
f'(\\ALL) "/" "{ALL_MUTF7}"'.encode(),
br'(\tRaSh) NIL "Papelera"',
'(\\JuNk) "/" "Courrier indésirable"'.encode(),
br'(\DRAFTS) "/" "Brouillons"',
br'(\fLaGgEd) "/" "Favoris"',
br'(\HasNoChildren) "/" "Sentimental"',
br'(\HasNoChildren) "/" "Archives 2024"',
]
class FakeConn:
def __init__(self, lines=LIST_LINES):
self.lines = lines
self.list_calls = 0
self.selects = []
self.logged_out = False
def list(self):
self.list_calls += 1
return "OK", self.lines
def select(self, folder, readonly=False):
self.selects.append((folder, readonly))
return "OK", []
def uid(self, command, *_args):
if command.upper() == "SEARCH":
return "OK", [b""]
return "OK", []
def noop(self):
return "OK", []
def logout(self):
self.logged_out = True
@pytest.mark.parametrize("module", [email_mcp, email_routes])
def test_list_parser_preserves_modified_utf7_and_matches_flags_case_insensitively(module):
name, attrs = module._parse_list_line(LIST_LINES[1])
assert name == SENT_MUTF7
assert attrs == frozenset({"\\hasnochildren", "\\sent"})
assert module._folder_role_from_flags(LIST_LINES[1]) == "sent"
@pytest.mark.parametrize("module", [email_mcp, email_routes])
def test_all_special_use_flags_have_distinct_roles(module):
expected = {
"\\sent": "sent",
"\\trash": "trash",
"\\junk": "junk",
"\\archive": "archive",
"\\all": "all",
"\\drafts": "drafts",
"\\flagged": "flagged",
}
for flag, role in expected.items():
assert module._folder_role_from_flags(f'({flag}) "/" "opaque-{role}"') == role
assert module._folder_role_from_flags(r'(\HasNoChildren) "/" "Sentimental"') == ""
assert module._folder_role_from_name("Sentimental") == ""
assert module._folder_role_from_name("Archives 2024") == ""
@pytest.mark.parametrize(
("resolver", "module"),
[
(email_mcp._resolve_folder, email_mcp),
(email_routes._resolve_mail_folder, email_routes),
],
)
def test_resolution_prefers_actual_name_then_flag_then_exact_legacy_candidate(resolver, module):
conn = FakeConn()
assert resolver(conn, SENT_MUTF7, "trash") == SENT_MUTF7
assert resolver(conn, "Sent", "sent") == SENT_MUTF7
assert resolver(conn, "All Mail", "all") == ALL_MUTF7
assert resolver(conn, "Archive", "archive") == "Archiv"
assert resolver(conn, "Archives 2024", module._folder_role_from_name("Archives 2024")) == "Archives 2024"
assert resolver(conn, "Missing Label", "") == "Missing Label"
@pytest.mark.parametrize(
"resolver",
[email_mcp._resolve_folder, email_routes._resolve_mail_folder],
)
def test_archive_resolution_prefers_archive_but_falls_back_to_all(resolver):
archive_and_all = FakeConn([
br'(\All) "/" "Todo"',
br'(\Archive) "/" "Archiv"',
])
assert resolver(archive_and_all, "Archive", "archive") == "Archiv"
all_only = FakeConn([br'(\All) "/" "Todo"'])
assert resolver(all_only, "Archive", "archive") == "Todo"
def test_mcp_list_and_search_select_localized_special_use_mailboxes(monkeypatch):
monkeypatch.setattr(email_mcp, "_fixture_email_enabled", lambda: False)
monkeypatch.setattr(email_mcp, "_get_cached_summaries", lambda: {})
list_conn = FakeConn()
monkeypatch.setattr(email_mcp, "_imap_connect", lambda _account=None: list_conn)
assert email_mcp._list_emails(folder="Sent") == []
assert list_conn.selects == [(f'"{SENT_MUTF7}"', True)]
assert list_conn.list_calls == 1
search_conn = FakeConn()
monkeypatch.setattr(email_mcp, "_imap_connect", lambda _account=None: search_conn)
assert email_mcp._search_emails("needle") == []
assert search_conn.selects == [
('"INBOX"', True),
(f'"{SENT_MUTF7}"', True),
(f'"{ALL_MUTF7}"', True),
('"Archiv"', True),
]
assert search_conn.list_calls == 1
def _route_endpoint(router, path):
return next(route.endpoint for route in router.routes if route.path == path)
def test_rest_folder_api_returns_opaque_names_with_one_to_one_roles(monkeypatch, tmp_path):
conn = FakeConn()
@contextmanager
def fake_imap(_account_id=None, owner=""):
yield conn
monkeypatch.setattr(email_routes, "_start_poller", lambda: None)
monkeypatch.setattr(email_routes, "DATA_DIR", tmp_path)
monkeypatch.setattr(email_routes, "_imap", fake_imap)
endpoint = _route_endpoint(email_routes.setup_email_routes(), "/api/email/folders")
result = asyncio.run(endpoint(account_id="localized-folders", cached_only=0, owner="owner"))
assert result["folders"] == [
"INBOX",
SENT_MUTF7,
"Archiv",
ALL_MUTF7,
"Papelera",
"Courrier indésirable",
"Brouillons",
"Favoris",
"Sentimental",
"Archives 2024",
]
assert result["roles"] == {
"INBOX": "inbox",
SENT_MUTF7: "sent",
"Archiv": "archive",
ALL_MUTF7: "all",
"Papelera": "trash",
"Courrier indésirable": "junk",
"Brouillons": "drafts",
"Favoris": "flagged",
}
def test_rest_list_resolves_sent_alias_before_select(monkeypatch, tmp_path):
conn = FakeConn()
monkeypatch.setattr(email_routes, "_start_poller", lambda: None)
monkeypatch.setattr(email_routes, "DATA_DIR", tmp_path)
monkeypatch.setattr(email_routes, "_imap_connect", lambda _account_id=None, owner="": conn)
endpoint = _route_endpoint(email_routes.setup_email_routes(), "/api/email/list")
result = asyncio.run(endpoint(
folder="Sent",
limit=1,
offset=0,
filter="all",
from_addr=None,
account_id="localized-list",
has_attachments=0,
cached_only=0,
cache_bust="test",
owner="owner",
))
assert result["folder"] == SENT_MUTF7
assert conn.selects == [(f'"{SENT_MUTF7}"', True)]
def test_ui_folder_logic_uses_server_roles_without_name_substrings():
inbox = Path("static/js/emailInbox.js").read_text()
library = Path("static/js/emailLibrary.js").read_text()
assert "export function folderRole(folder, roles" in inbox
assert "export function folderLabelKey(folder, roles" in inbox
assert "Object.hasOwn(roles, raw)" in inbox
assert "data.roles" in inbox
assert "data.roles" in library
assert "data-i18n" in inbox
assert "ui.email.folder.scheduled" in library
assert "folderRole(cardFolder, roles) === 'sent'" in library
assert "/sent/i.test" not in library
assert ".includes(String(p).toLowerCase())" not in library
for key in (
"ui.email.folder.inbox",
"ui.email.folder.sent",
"ui.email.folder.flagged",
"ui.email.folder.all",
"ui.email.folder.archive",
"ui.email.folder.junk",
"ui.email.folder.trash",
"ui.email.folder.drafts",
):
assert f"'{key}'" in inbox

407
tests/test_i18n_contract.py Normal file
View file

@ -0,0 +1,407 @@
import json
import re
import shutil
import subprocess
import unicodedata
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
I18N_DIR = ROOT / "static" / "i18n"
STEAM_LOCALES = [
"ar",
"bg",
"zh-CN",
"zh-TW",
"cs",
"da",
"nl",
"en",
"fi",
"fr",
"de",
"el",
"hu",
"id",
"it",
"ja",
"ko",
"ms",
"no",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"es",
"es-419",
"sv",
"th",
"tr",
"uk",
"vi",
]
PLACEHOLDER = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}")
BIDI_CONTROL = re.compile(r"[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]")
HTML_TAG = re.compile(r"</?[a-z][^>]*>", re.IGNORECASE)
MACHINE_MARKER = re.compile(r"ZXQ|QXZ|ZXXZ|ZXZ|QLOCK", re.IGNORECASE)
HTML_ENTITY = re.compile(r"&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]+);", re.IGNORECASE)
SCRIPT_RANGES = {
"Arabic": ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF)),
"Cyrillic": ((0x0400, 0x052F),),
"Greek": ((0x0370, 0x03FF), (0x1F00, 0x1FFF)),
"Han": ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF)),
"Hangul": ((0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF)),
"Hiragana": ((0x3040, 0x309F),),
"Katakana": ((0x30A0, 0x30FF), (0x31F0, 0x31FF)),
"Thai": ((0x0E00, 0x0E7F),),
}
SCRIPT_PATTERN = re.compile(
"|".join(
(
f"(?P<{script}>["
+ "".join(
f"\\U{start:08x}-\\U{end:08x}" for start, end in ranges
)
+ "]+)"
)
for script, ranges in SCRIPT_RANGES.items()
)
)
EXPECTED_NON_LATIN_SCRIPTS = {
"ar": {"Arabic"},
"bg": {"Cyrillic"},
"el": {"Greek"},
"ja": {"Han", "Hiragana", "Katakana"},
"ko": {"Han", "Hangul"},
"ru": {"Cyrillic"},
"th": {"Thai"},
"uk": {"Cyrillic"},
"zh-CN": {"Han"},
"zh-TW": {"Han"},
}
def _json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def _unexpected_script_runs(locale: str, target: str, source: str):
expected = EXPECTED_NON_LATIN_SCRIPTS.get(locale, set())
findings = []
for match in SCRIPT_PATTERN.finditer(target):
script = match.lastgroup
if script in expected:
continue
raw_run = match.group()
run = "".join(
char
for char in raw_run
if unicodedata.category(char).startswith("L")
)
if run and raw_run not in source and run not in source:
findings.append((script, run))
return findings
def test_registry_is_the_steam_full_platform_contract():
registry = _json(I18N_DIR / "registry.json")
assert registry["support_level"] == "full-platform"
assert registry["source"] == (
"https://partner.steamgames.com/doc/store/localization/languages"
)
assert list(registry["locales"]) == STEAM_LOCALES
assert registry["default_locale"] == "en"
assert registry["locales"]["ar"]["dir"] == "rtl"
assert all(
metadata["dir"] == "ltr"
for locale, metadata in registry["locales"].items()
if locale != "ar"
)
def test_catalog_entries_are_safe_and_complete():
english = _json(I18N_DIR / "en.json")
ledger = _json(I18N_DIR / "ledger.json")
expected_keys = set(english)
assert len(expected_keys) == ledger["source_count"]
assert not any(HTML_ENTITY.search(value) for value in english.values())
for locale in STEAM_LOCALES:
catalog = _json(I18N_DIR / f"{locale}.json")
assert set(catalog) == expected_keys, locale
for key, target in catalog.items():
source = english[key]
assert isinstance(target, str) and target.strip(), (locale, key)
assert not BIDI_CONTROL.search(target), (locale, key)
assert not HTML_TAG.search(target), (locale, key)
assert not MACHINE_MARKER.search(target), (locale, key)
assert not any(unicodedata.category(char) == "Cf" for char in target), (
locale,
key,
)
assert sorted(PLACEHOLDER.findall(target)) == sorted(
PLACEHOLDER.findall(source)
), (locale, key)
def test_catalog_tool_never_translates_or_generates_runtime_text():
source = (ROOT / "scripts" / "i18n-catalog.mjs").read_text(encoding="utf-8")
assert "translate.googleapis.com" not in source
assert "translate-all" not in source
assert "fetch(" not in source
runtime = (ROOT / "static" / "js" / "i18n.js").read_text(encoding="utf-8")
assert "fetchCatalog" in runtime
assert "translate.googleapis.com" not in runtime
def test_catalog_ledger_preserves_source_line_locations():
source = (
"Choose the language used by Odysseus. "
"Your choice is saved in this browser."
)
index_lines = (ROOT / "static" / "index.html").read_text(
encoding="utf-8"
).splitlines()
source_line = next(
line_number
for line_number, line in enumerate(index_lines, start=1)
if source in line
)
ledger = _json(I18N_DIR / "ledger.json")
record = next(entry for entry in ledger["entries"] if entry["source"] == source)
assert f"static/index.html:{source_line}" in record["locations"]
def test_executable_catalog_fragments_remain_byte_identical():
english = _json(I18N_DIR / "en.json")
opaque_sources = {
"capture-pane -t {0} -p -S -500",
"has-session -t {0}",
"kill-session -t {0}",
"tmux kill-session -t {0} 2>/dev/null",
"pkill -f vllm",
"@font-face { font-family: '{0}'; src: url('{1}') format('{2}'); "
"font-display: swap; }",
"ms)",
}
protected_fragments = {
"ui.ollama.is.not.installed.on.this.server.run.curl.fssl":
"curl -fsSL https://ollama.com/install.sh | sh",
"ui.llama.cpp.python.server.is.not.installed.run.pip.install":
'pip install "llama-cpp-python[server]"',
"ui.no.background.removal.model.available.install.rembg.pip.install.rembg":
"pip install rembg",
"ui.fix.properly.pip.install.matching.version": "pip install",
"ui.unknown.action.value.use.list.search.view.add.update.delete":
"list/search/view/add/update/delete/toggle_item",
}
assert opaque_sources.isdisjoint(english.values())
for locale in STEAM_LOCALES:
catalog = _json(I18N_DIR / f"{locale}.json")
for key, fragment in protected_fragments.items():
assert fragment in catalog[key], (locale, key, catalog[key])
@pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
def test_canonical_catalog_validator_and_source_snapshot():
result = subprocess.run(
["node", "scripts/i18n-catalog.mjs", "validate"],
cwd=ROOT,
capture_output=True,
text=True,
timeout=90,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_non_english_catalogs_contain_real_localized_content():
english = _json(I18N_DIR / "en.json")
meaningful = [
key
for key, value in english.items()
if re.search(r"[A-Za-z]{3}", value) and len(value) >= 4
]
for locale in STEAM_LOCALES:
if locale == "en":
continue
catalog = _json(I18N_DIR / f"{locale}.json")
assert len(catalog) / len(english) >= 0.90, (
locale,
len(catalog),
len(english),
)
changed = sum(
catalog.get(key, english[key]) != english[key] for key in meaningful
)
assert changed / len(meaningful) >= 0.90, (locale, changed, len(meaningful))
def test_catalogs_have_no_unexpected_script_contamination():
english = _json(I18N_DIR / "en.json")
findings = []
for locale in STEAM_LOCALES:
if locale == "en":
continue
catalog = _json(I18N_DIR / f"{locale}.json")
for key, source in english.items():
if key not in catalog:
continue
for script, run in _unexpected_script_runs(
locale, catalog[key], source
):
findings.append((locale, key, script, run, catalog[key]))
assert not findings, findings
def test_semantic_email_folder_labels_match_their_legacy_catalog_entries():
duplicate_pairs = {
"ui.email.folder.junk": "ui.junk.86c7d94c",
"ui.email.folder.flagged": "ui.starred.e61561a8",
"ui.email.folder.trash": "ui.trash",
}
for locale in STEAM_LOCALES:
catalog = _json(I18N_DIR / f"{locale}.json")
for semantic_key, legacy_key in duplicate_pairs.items():
assert catalog.get(semantic_key) == catalog.get(legacy_key), (
locale,
semantic_key,
legacy_key,
)
def test_unexpected_script_guard_handles_leaks_and_source_literals():
assert _unexpected_script_runs(
"bg", "Инсталирайте الأوامر", "Install the command"
) == [("Arabic", "الأوامر")]
assert _unexpected_script_runs(
"vi", "Xóa tác vụ 已完成", "Clear completed task"
) == [("Han", "已完成")]
assert _unexpected_script_runs(
"vi", "Mở 日本語 README", "Open 日本語 README"
) == []
def test_auth_app_pwa_and_offline_shell_are_wired():
index = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
login = (ROOT / "static" / "login.html").read_text(encoding="utf-8")
worker = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
assert index.index("/static/js/i18n.js") < index.index("/static/js/storage.js")
assert 'id="set-interface-language"' in index
assert 'data-language-select' in index
assert "/static/js/i18n.js" in login
assert 'id="login-interface-language"' in login
assert "/static/i18n/registry.json" in worker
assert "/static/i18n/en.json" in worker
assert "/static/i18n/fr.json" not in worker
assert "setI18nText(setupNote, 'auth.first_time_setup'" in login
assert "auth.two_factor_code" in login
init = (ROOT / "static" / "js" / "init.js").read_text(encoding="utf-8")
settings = (ROOT / "static" / "js" / "settings.js").read_text(encoding="utf-8")
assert "'odysseus.locale'" in init
assert "'odysseus.locale'" in settings
for locale in STEAM_LOCALES:
manifest = _json(ROOT / "static" / f"manifest.{locale}.json")
assert manifest["lang"] == locale
assert manifest["name"] == "Odysseus"
@pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
def test_runtime_locale_matching_and_interpolation_execute_in_node():
script = """
const { interpolate, matchLocale } = await import('./static/js/i18n.js');
const { isCodeLiteral, structurallyValid } = await import(
'./scripts/i18n-catalog.mjs'
);
const registry = JSON.parse(
await (await import('node:fs/promises')).readFile(
'./static/i18n/registry.json', 'utf8'
)
);
console.log(JSON.stringify({
traditional: matchLocale(['zh-Hant-HK'], registry),
latam: matchLocale(['es-MX'], registry),
portugal: matchLocale(['pt-PT'], registry),
norwegian: matchLocale(['nb-NO'], registry),
fallback: matchLocale(['xx-ZZ'], registry),
interpolation: interpolate('Saved {count} files for {name}.', {
count: 3,
name: 'Ada',
}),
commandLiteral: isCodeLiteral('capture-pane -t {0} -p -S -500'),
corruptPipRejected: structurallyValid(
'Fix properly: pip install matching version',
'Corriger correctement : pip installer la version correspondante',
),
preservedPipAccepted: structurallyValid(
'Fix properly: pip install matching version',
'Corriger correctement : pip install la version correspondante',
),
}));
"""
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
timeout=15,
)
assert json.loads(result.stdout) == {
"traditional": "zh-TW",
"latam": "es-419",
"portugal": "pt",
"norwegian": "no",
"fallback": "en",
"interpolation": "Saved 3 files for Ada.",
"commandLiteral": True,
"corruptPipRejected": False,
"preservedPipAccepted": True,
}
@pytest.mark.skipif(
not shutil.which("node") or not shutil.which("chromium"),
reason="node and chromium are required",
)
def test_login_runtime_in_real_browser():
result = subprocess.run(
["node", "tests/i18n_browser_acceptance.mjs"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
timeout=45,
)
payload = json.loads(result.stdout)
assert payload["french"]["options"] == len(STEAM_LOCALES)
assert payload["arabic"]["dir"] == "rtl"
assert payload["index"]["selected"] == "fr"
assert len(payload["allLocales"]["states"]) == len(STEAM_LOCALES)
assert payload["allLocales"]["payload"]["initialFiles"] == [
"en.json",
"registry.json",
]
assert len(payload["allLocales"]["payload"]["loadedFiles"]) == (
len(STEAM_LOCALES) + 1
)
assert payload["allLocales"]["payload"]["requestCount"] == (
len(STEAM_LOCALES) + 1
)
assert payload["allLocales"]["payload"]["cachedRepeatRequests"] == 0
assert payload["allLocales"]["payload"]["decodedBodyBytes"] > 0
assert payload["allLocales"]["performance"]["samples"] == len(STEAM_LOCALES)
assert payload["allLocales"]["performance"]["totalMs"] >= 0