mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-09 10:39:11 +02:00
Merge c04b1f98be into 42da399b4d
This commit is contained in:
commit
b686afab49
90 changed files with 221737 additions and 343 deletions
|
|
@ -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
77
docs/localization.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
53
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
936
scripts/i18n-catalog.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
201
static/fonts/NotoSansArabic-OFL.txt
Normal file
201
static/fonts/NotoSansArabic-OFL.txt
Normal 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.
|
||||
BIN
static/fonts/NotoSansArabic-Regular.woff2
Normal file
BIN
static/fonts/NotoSansArabic-Regular.woff2
Normal file
Binary file not shown.
BIN
static/fonts/NotoSansArabic-SemiBold.woff2
Normal file
BIN
static/fonts/NotoSansArabic-SemiBold.woff2
Normal file
Binary file not shown.
5376
static/i18n/ar.json
Normal file
5376
static/i18n/ar.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/bg.json
Normal file
5376
static/i18n/bg.json
Normal file
File diff suppressed because it is too large
Load diff
94
static/i18n/brands.json
Normal file
94
static/i18n/brands.json
Normal 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
5376
static/i18n/cs.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/da.json
Normal file
5376
static/i18n/da.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/de.json
Normal file
5376
static/i18n/de.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/el.json
Normal file
5376
static/i18n/el.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/en.json
Normal file
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
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
5376
static/i18n/es.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/fi.json
Normal file
5376
static/i18n/fi.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/fr.json
Normal file
5376
static/i18n/fr.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/hu.json
Normal file
5376
static/i18n/hu.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/id.json
Normal file
5376
static/i18n/id.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/it.json
Normal file
5376
static/i18n/it.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/ja.json
Normal file
5376
static/i18n/ja.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/ko.json
Normal file
5376
static/i18n/ko.json
Normal file
File diff suppressed because it is too large
Load diff
49551
static/i18n/ledger.json
Normal file
49551
static/i18n/ledger.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/ms.json
Normal file
5376
static/i18n/ms.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/nl.json
Normal file
5376
static/i18n/nl.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/no.json
Normal file
5376
static/i18n/no.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/pl.json
Normal file
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
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
5376
static/i18n/pt.json
Normal file
File diff suppressed because it is too large
Load diff
167
static/i18n/registry.json
Normal file
167
static/i18n/registry.json
Normal 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
5376
static/i18n/ro.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/ru.json
Normal file
5376
static/i18n/ru.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/sv.json
Normal file
5376
static/i18n/sv.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/th.json
Normal file
5376
static/i18n/th.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/tr.json
Normal file
5376
static/i18n/tr.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/uk.json
Normal file
5376
static/i18n/uk.json
Normal file
File diff suppressed because it is too large
Load diff
5376
static/i18n/vi.json
Normal file
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
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
5376
static/i18n/zh-TW.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
586
static/js/i18n.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;">✓ 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;">✓ 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;">✓ 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
32
static/manifest.ar.json
Normal 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
32
static/manifest.bg.json
Normal 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
32
static/manifest.cs.json
Normal 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
32
static/manifest.da.json
Normal 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
32
static/manifest.de.json
Normal 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
32
static/manifest.el.json
Normal 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
32
static/manifest.en.json
Normal 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"
|
||||
}
|
||||
32
static/manifest.es-419.json
Normal file
32
static/manifest.es-419.json
Normal 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
32
static/manifest.es.json
Normal 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
32
static/manifest.fi.json
Normal 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
32
static/manifest.fr.json
Normal 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
32
static/manifest.hu.json
Normal 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
32
static/manifest.id.json
Normal 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
32
static/manifest.it.json
Normal 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
32
static/manifest.ja.json
Normal 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
32
static/manifest.ko.json
Normal 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
32
static/manifest.ms.json
Normal 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
32
static/manifest.nl.json
Normal 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
32
static/manifest.no.json
Normal 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
32
static/manifest.pl.json
Normal 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"
|
||||
}
|
||||
32
static/manifest.pt-BR.json
Normal file
32
static/manifest.pt-BR.json
Normal 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
32
static/manifest.pt.json
Normal 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
32
static/manifest.ro.json
Normal 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
32
static/manifest.ru.json
Normal 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
32
static/manifest.sv.json
Normal 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
32
static/manifest.th.json
Normal 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
32
static/manifest.tr.json
Normal 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
32
static/manifest.uk.json
Normal 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
32
static/manifest.vi.json
Normal 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"
|
||||
}
|
||||
32
static/manifest.zh-CN.json
Normal file
32
static/manifest.zh-CN.json
Normal 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"
|
||||
}
|
||||
32
static/manifest.zh-TW.json
Normal file
32
static/manifest.zh-TW.json
Normal 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"
|
||||
}
|
||||
175
static/style.css
175
static/style.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
897
tests/i18n_browser_acceptance.mjs
Normal file
897
tests/i18n_browser_acceptance.mjs
Normal 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 });
|
||||
}
|
||||
155
tests/test_agent_intent_multilingual.py
Normal file
155
tests/test_agent_intent_multilingual.py
Normal 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)
|
||||
227
tests/test_email_special_use_localized.py
Normal file
227
tests/test_email_special_use_localized.py
Normal 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
407
tests/test_i18n_contract.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue