Language and theme follow the account: PATCH /me for the theme, /me's language by default
The server's /api/v2 now has PATCH /me {language, theme} and /me lists its languages and
themes. The theme is the account's and choosing one saves it there (a refusal is said);
a server whose /me lists no themes keeps the earlier choice in the app. The language is the
account's unless Settings chose this computer's or another (kept in the app, as the phone
does); a change of the account's opens the window again in it, once.
Fixtures re-pinned to the server's dev (f7f2ff2), with the new /api/v2 parity ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
23 files changed
+783
-84
No files matched your search
+4
-3
@@ -16,9 +16,10 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
the account has another computer linked, a chip before the folder chooses it (this computer is
|
the account has another computer linked, a chip before the folder chooses it (this computer is
|
||||||
the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal
|
the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal
|
||||||
before the first message opens there too.
|
before the first message opens there too.
|
||||||
- **Language and theme in Settings.** Under Appearance: the language (this computer's, or any of
|
- **Language and theme in Settings.** Under Appearance. The language is the account's, as the
|
||||||
the app's nine; the window opens again in it) and the theme (the account's, or Moria or Shire).
|
server says it, unless this computer's or another of the app's nine is chosen (kept in the app;
|
||||||
Both are kept in the app on this computer; the account's own stay as they are.
|
the window opens again in it). The theme is the account's, and choosing one saves it there, so
|
||||||
|
the web interface and the phone follow; on a server that cannot be told, it is kept in the app.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
|||||||
FROM="tests/fixtures/api_v2"
|
FROM="tests/fixtures/api_v2"
|
||||||
OUT="tests/ui/fixtures"
|
OUT="tests/ui/fixtures"
|
||||||
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
||||||
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
|
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json
|
||||||
|
me.json folders.json folder.json search.json memories.json memory.json scope.json push_schedule.json push_schedule_failed.json
|
||||||
|
event_schedules.json schedule_run.json chat.json chats_listed.json files_listing.json"
|
||||||
|
|
||||||
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
||||||
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
||||||
|
|||||||
+37
-7
@@ -469,6 +469,8 @@ KEYS = (
|
|||||||
"The account's (%(theme)s)",
|
"The account's (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.",
|
"Kept in this app on this computer. The window opens again in the language chosen.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.",
|
"Kept in this app on this computer; the account's theme stays as it is.",
|
||||||
|
"The account's (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||||
@@ -710,6 +712,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.",
|
||||||
|
"The account's (%(language)s)": "Podle účtu (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.",
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
||||||
@@ -946,6 +950,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.",
|
||||||
|
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.",
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
||||||
@@ -1182,6 +1188,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.",
|
||||||
|
"The account's (%(language)s)": "El de la cuenta (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.",
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
||||||
@@ -1418,6 +1426,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.",
|
||||||
|
"The account's (%(language)s)": "Celle du compte (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.",
|
||||||
},
|
},
|
||||||
"hu": {
|
"hu": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
||||||
@@ -1654,6 +1664,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.",
|
||||||
|
"The account's (%(language)s)": "A fióké (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.",
|
||||||
},
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
||||||
@@ -1890,6 +1902,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.",
|
||||||
|
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.",
|
||||||
},
|
},
|
||||||
"pl": {
|
"pl": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
||||||
@@ -2126,6 +2140,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.",
|
||||||
|
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.",
|
||||||
},
|
},
|
||||||
"sk": {
|
"sk": {
|
||||||
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
||||||
@@ -2362,6 +2378,8 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
||||||
"Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.",
|
"Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.",
|
||||||
"Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.",
|
"Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.",
|
||||||
|
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2426,9 +2444,10 @@ def main() -> None:
|
|||||||
"// script's own table. Do not edit; run the script.\n"
|
"// script's own table. Do not edit; run the script.\n"
|
||||||
"//\n"
|
"//\n"
|
||||||
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n"
|
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n"
|
||||||
"// missing entry is the English. The language is the one chosen in Settings (kept in\n"
|
"// missing entry is the English. The language is the account's (kept from /me as\n"
|
||||||
"// localStorage as `lembas.language`), else the system's (navigator.language), English\n"
|
"// `lembas.account-language`) unless Settings chose this computer's or another\n"
|
||||||
"// unless it is one of these. Elements marked `data-i18n` (their text) and\n"
|
"// (`lembas.language`); the system's is navigator.language. English unless it is one of\n"
|
||||||
|
"// these. Elements marked `data-i18n` (their text) and\n"
|
||||||
"// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n"
|
"// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n"
|
||||||
"// are translated once the page is parsed; this script is deferred, before the page's.\n"
|
"// are translated once the page is parsed; this script is deferred, before the page's.\n"
|
||||||
'"use strict"\n'
|
'"use strict"\n'
|
||||||
@@ -2443,12 +2462,23 @@ def main() -> None:
|
|||||||
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
|
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
|
||||||
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
|
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
|
||||||
"})()\n"
|
"})()\n"
|
||||||
"const LEMBAS_CHOSEN_LANG = (() => {\n"
|
"// Settings' choice: \"\" for the account's language (as /me last said it), \"system\", or a code.\n"
|
||||||
' let code = ""\n'
|
"const LEMBAS_CHOICE = (() => {\n"
|
||||||
" try {\n"
|
" try {\n"
|
||||||
' code = localStorage.getItem("lembas.language") || ""\n'
|
' return localStorage.getItem("lembas.language") || ""\n'
|
||||||
|
" } catch {\n"
|
||||||
|
' return ""\n'
|
||||||
|
" }\n"
|
||||||
|
"})()\n"
|
||||||
|
"const LEMBAS_CHOSEN_LANG = (() => {\n"
|
||||||
|
" const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)\n"
|
||||||
|
' if (LEMBAS_CHOICE === "system") return ""\n'
|
||||||
|
" if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE\n"
|
||||||
|
' let account = ""\n'
|
||||||
|
" try {\n"
|
||||||
|
' account = localStorage.getItem("lembas.account-language") || ""\n'
|
||||||
" } catch {}\n"
|
" } catch {}\n"
|
||||||
' return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""\n'
|
' return has(account) ? account : ""\n'
|
||||||
"})()\n"
|
"})()\n"
|
||||||
"const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n"
|
"const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n"
|
||||||
"document.documentElement.lang = LEMBAS_LANG\n"
|
"document.documentElement.lang = LEMBAS_LANG\n"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "chat1",
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": false,
|
||||||
|
"mode": "",
|
||||||
|
"effort": "low",
|
||||||
|
"device": null,
|
||||||
|
"device_session_id": null,
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false,
|
||||||
|
"cap": 32
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"chats": [
|
||||||
|
{
|
||||||
|
"id": "chat1",
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": false,
|
||||||
|
"mode": "",
|
||||||
|
"effort": "low",
|
||||||
|
"device": null,
|
||||||
|
"device_session_id": null,
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": null,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"more": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"version": "d2f307bf1c7b8d6b",
|
||||||
|
"schedules": [
|
||||||
|
{
|
||||||
|
"id": "sched1",
|
||||||
|
"title": "Waybread check",
|
||||||
|
"instruction": "Check the waybread is wrapped.",
|
||||||
|
"request": "every second Monday at 7:30",
|
||||||
|
"rule": {
|
||||||
|
"at": {
|
||||||
|
"nth": [
|
||||||
|
{
|
||||||
|
"n": 2,
|
||||||
|
"weekday": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"times": [
|
||||||
|
"07:30"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "On the second Monday of each month at 07:30",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"enabled": true,
|
||||||
|
"next_run": "2026-01-12T07:30:00+00:00",
|
||||||
|
"next_runs": [
|
||||||
|
"2026-01-12T07:30:00+00:00",
|
||||||
|
"2026-02-09T07:30:00+00:00",
|
||||||
|
"2026-03-09T07:30:00+00:00",
|
||||||
|
"2026-04-13T07:30:00+00:00",
|
||||||
|
"2026-05-11T07:30:00+00:00"
|
||||||
|
],
|
||||||
|
"target": "chat",
|
||||||
|
"mode": "fresh",
|
||||||
|
"notify": "matters",
|
||||||
|
"catch_up": "one",
|
||||||
|
"grace_minutes": null,
|
||||||
|
"model": "qwen",
|
||||||
|
"effort": "high",
|
||||||
|
"agents": null,
|
||||||
|
"tools": [
|
||||||
|
"files",
|
||||||
|
"recall",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"memory",
|
||||||
|
"skills",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"origin": "user",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"fired_count": 3,
|
||||||
|
"failure_streak": 0,
|
||||||
|
"watch": null,
|
||||||
|
"last_error": null,
|
||||||
|
"last_run": {
|
||||||
|
"id": "run1",
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"status": "ok",
|
||||||
|
"timing": "late",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"waiting_for": null,
|
||||||
|
"retry_at": null,
|
||||||
|
"progress": null,
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "GPT-OSS",
|
||||||
|
"reason": "switched",
|
||||||
|
"switched_from": "Qwen"
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 1200,
|
||||||
|
"completion": 340
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"message_id": "msg2",
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "All the waybread is wrapped.",
|
||||||
|
"error": null,
|
||||||
|
"notified": true,
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"running": false,
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"path": "/home/frodo",
|
||||||
|
"parent": "/home",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "recipes",
|
||||||
|
"path": "/home/frodo/recipes",
|
||||||
|
"kind": "dir",
|
||||||
|
"size": 0,
|
||||||
|
"mime": "",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lembas.md",
|
||||||
|
"path": "/home/frodo/lembas.md",
|
||||||
|
"kind": "file",
|
||||||
|
"size": 27,
|
||||||
|
"mime": "text/markdown",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"folder": {
|
||||||
|
"id": "fold1",
|
||||||
|
"name": "Waybread",
|
||||||
|
"parent_id": null,
|
||||||
|
"kind": "",
|
||||||
|
"position": 0,
|
||||||
|
"collapsed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "fold1",
|
||||||
|
"name": "Waybread",
|
||||||
|
"parent_id": null,
|
||||||
|
"kind": "",
|
||||||
|
"position": 0,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fold2",
|
||||||
|
"name": "Recipes",
|
||||||
|
"parent_id": "fold1",
|
||||||
|
"kind": "chat",
|
||||||
|
"position": 1,
|
||||||
|
"collapsed": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"api": 2,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"account": {
|
||||||
|
"id": "user1",
|
||||||
|
"name": "Frodo",
|
||||||
|
"email": "frodo@shire.test"
|
||||||
|
},
|
||||||
|
"language": "sk",
|
||||||
|
"theme": "shire",
|
||||||
|
"default_model": "qwen",
|
||||||
|
"permissions": {
|
||||||
|
"chat.create": true,
|
||||||
|
"chat.delete": false,
|
||||||
|
"chat.model_select": true,
|
||||||
|
"chat.system_prompt": true,
|
||||||
|
"chat.params": true,
|
||||||
|
"files.upload": true,
|
||||||
|
"tools.agent": true,
|
||||||
|
"audio.transcribe": true,
|
||||||
|
"audio.listen": true
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"stt": true,
|
||||||
|
"tts": false,
|
||||||
|
"search": true,
|
||||||
|
"fetch": true,
|
||||||
|
"images": false
|
||||||
|
},
|
||||||
|
"scopes": [
|
||||||
|
"app",
|
||||||
|
"models"
|
||||||
|
],
|
||||||
|
"language_setting": "sk",
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": "en",
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sk",
|
||||||
|
"name": "Slovenčina"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"id": "moria",
|
||||||
|
"label": "Moria",
|
||||||
|
"scheme": "dark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shire",
|
||||||
|
"label": "Shire",
|
||||||
|
"scheme": "light"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"memories": [
|
||||||
|
{
|
||||||
|
"id": "mem1",
|
||||||
|
"content": "Prefers lembas warm.",
|
||||||
|
"author": "user",
|
||||||
|
"data_group": {
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"memory": {
|
||||||
|
"id": "mem1",
|
||||||
|
"content": "Prefers lembas warm.",
|
||||||
|
"author": "user",
|
||||||
|
"data_group": {
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"run_id": "run1",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"status": "ok",
|
||||||
|
"visibility": "private",
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"ask": "",
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"pause_label": "Pause",
|
||||||
|
"title": "LLeMbas",
|
||||||
|
"body": "A scheduled task has finished.",
|
||||||
|
"url": "/scheduled/sched1?run=run1",
|
||||||
|
"kind": "schedule"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"run_id": "run3",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"status": "failed",
|
||||||
|
"visibility": "private",
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again",
|
||||||
|
"pause"
|
||||||
|
],
|
||||||
|
"ask": "pause",
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Failed 3 times in a row. Pause it?",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Failed"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again",
|
||||||
|
"pause"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again",
|
||||||
|
"pause": "Pause"
|
||||||
|
},
|
||||||
|
"ask": "pause"
|
||||||
|
},
|
||||||
|
"pause_label": "Pause",
|
||||||
|
"title": "LLeMbas",
|
||||||
|
"body": "A scheduled task failed 3 times in a row. Pause it?",
|
||||||
|
"url": "/scheduled/sched1?run=run3",
|
||||||
|
"kind": "schedule_failed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"run": {
|
||||||
|
"id": "run1",
|
||||||
|
"schedule_id": "sched1",
|
||||||
|
"status": "ok",
|
||||||
|
"timing": "late",
|
||||||
|
"missed": 0,
|
||||||
|
"due_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"started_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"finished_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"waiting_for": null,
|
||||||
|
"retry_at": null,
|
||||||
|
"progress": null,
|
||||||
|
"attempts": 1,
|
||||||
|
"model": {
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "GPT-OSS",
|
||||||
|
"reason": "switched",
|
||||||
|
"switched_from": "Qwen"
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"prompt": 1200,
|
||||||
|
"completion": 340
|
||||||
|
},
|
||||||
|
"cost": null,
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"message_id": "msg2",
|
||||||
|
"report_path": null,
|
||||||
|
"preview": "All the waybread is wrapped.",
|
||||||
|
"error": null,
|
||||||
|
"notified": true,
|
||||||
|
"notification": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "All the waybread is wrapped.",
|
||||||
|
"public": {
|
||||||
|
"title": "Waybread check",
|
||||||
|
"body": "Finished"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
"open",
|
||||||
|
"run_again"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"open": "Open",
|
||||||
|
"run_again": "Run again"
|
||||||
|
},
|
||||||
|
"ask": ""
|
||||||
|
},
|
||||||
|
"watch": null
|
||||||
|
},
|
||||||
|
"text": "All the waybread is *wrapped*."
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"scope": {
|
||||||
|
"families": [
|
||||||
|
{
|
||||||
|
"gate": "notes",
|
||||||
|
"label": "Notes",
|
||||||
|
"on": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "web",
|
||||||
|
"label": "Web search",
|
||||||
|
"on": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "baking",
|
||||||
|
"description": "Bake waybread.",
|
||||||
|
"on": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"bash:ls *"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"q": "lembas",
|
||||||
|
"in": "all",
|
||||||
|
"offered": [
|
||||||
|
"chats",
|
||||||
|
"files",
|
||||||
|
"memories"
|
||||||
|
],
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"kind": "chats",
|
||||||
|
"more": true,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "chat",
|
||||||
|
"title": "Waybread",
|
||||||
|
"snippet": "…how long does lembas keep & travel?",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
15,
|
||||||
|
21
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false,
|
||||||
|
"id": "chat1",
|
||||||
|
"agent": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "files",
|
||||||
|
"more": false,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"title": "bread.md",
|
||||||
|
"snippet": "/home/frodo/bread.md — Lembas recipe",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
23,
|
||||||
|
29
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false,
|
||||||
|
"path": "/home/frodo/bread.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "memories",
|
||||||
|
"more": false,
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"kind": "memory",
|
||||||
|
"id": "mem1",
|
||||||
|
"title": "",
|
||||||
|
"snippet": "Prefers lembas warm.",
|
||||||
|
"marks": [
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
14
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"archived": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+54
-22
@@ -994,57 +994,89 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th
|
|||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
// E5: the language and the theme, chosen in Settings and kept in this app.
|
// E5: the language and the theme. The server's /me (fixture me.json) offers its themes and takes
|
||||||
|
// one on PATCH /me; its language is the app's unless Settings chose this computer's or another.
|
||||||
|
|
||||||
|
const ME = fixture("me")
|
||||||
|
const meAs = (over = {}) => ({ ...ME, account: { id: "u1", name: "Ada Example", email: "ada@example.org" }, permissions: {}, ...over })
|
||||||
|
|
||||||
async function openSettings(p) {
|
async function openSettings(p) {
|
||||||
await p.click("#settings-btn")
|
await p.click("#settings-btn")
|
||||||
await p.waitForSelector("#set-look")
|
await p.waitForSelector("#set-look")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E5: the theme is the account's until one is chosen here; chosen, it is drawn at once and kept", async () => {
|
test("E5: the theme is the account's, as /me offers them; chosen, PATCH /me saves it and it is drawn at once", async () => {
|
||||||
|
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": meAs({ language: "en", theme: "moria" }) }), streams: {} })
|
||||||
|
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||||
|
await openSettings(p)
|
||||||
|
assert.equal(await p.inputValue("#set-theme"), "shire")
|
||||||
|
assert.deepEqual(await p.$$eval("#set-theme option", (o) => o.map((x) => x.textContent)), ["Moria — dark", "Shire — light"])
|
||||||
|
await p.selectOption("#set-theme", "moria")
|
||||||
|
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
|
||||||
|
const [saved] = await p.evaluate(() => window.__mock.api("PATCH", "/me"))
|
||||||
|
assert.deepEqual(saved.args.body, { theme: "moria" })
|
||||||
|
const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name))
|
||||||
|
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
|
||||||
|
assert.equal(await p.inputValue("#set-theme"), "moria")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("E5: a theme the server refuses is said, and the look stays", async () => {
|
||||||
|
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": { __error: { status: 400, detail: "That theme is not offered." } } }), streams: {} })
|
||||||
|
await openSettings(p)
|
||||||
|
await p.selectOption("#set-theme", "moria")
|
||||||
|
await p.waitForSelector("#set-look [role=alert]")
|
||||||
|
assert.match(await p.textContent("#set-look [role=alert]"), /not offered/)
|
||||||
|
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire")
|
||||||
|
assert.equal(await p.inputValue("#set-theme"), "shire")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("E5: a server that cannot be told: the theme chosen is kept in the app, over the account's", async () => {
|
||||||
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} })
|
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} })
|
||||||
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||||
await openSettings(p)
|
await openSettings(p)
|
||||||
assert.equal(await p.inputValue("#set-theme"), "")
|
assert.equal(await p.inputValue("#set-theme"), "")
|
||||||
assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/)
|
assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/)
|
||||||
await p.selectOption("#set-theme", "moria")
|
await p.selectOption("#set-theme", "moria")
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
|
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
|
||||||
const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name))
|
assert.equal(await p.evaluate(() => window.__mock.api("PATCH", "/me").length), 0, "nothing sent")
|
||||||
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
|
|
||||||
// Opened again: still Moria, over the account's Shire.
|
|
||||||
await p.reload()
|
await p.reload()
|
||||||
await p.waitForSelector("#shell:not([hidden])")
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme"))
|
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme"))
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
|
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
|
||||||
await openSettings(p)
|
await openSettings(p)
|
||||||
assert.equal(await p.inputValue("#set-theme"), "moria")
|
|
||||||
await p.selectOption("#set-theme", "")
|
await p.selectOption("#set-theme", "")
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire", "back to the account's")
|
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("E5: a language chosen here opens the window again in it, the shells let go first; the system's again undoes it", async () => {
|
test("E5: the account's language is the app's; this computer's or another, chosen, opens the window again in it, the shells let go first", async () => {
|
||||||
const p = await page(b3())
|
const p = await page({ ...b3(), routes: { ...b3().routes, "GET /me": meAs({ language: "de", theme: "moria" }) } })
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
|
// The account says German: the window opens again in it, once.
|
||||||
|
await p.waitForFunction(() => document.documentElement.lang === "de", null, { timeout: 15000 })
|
||||||
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
|
assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen")
|
||||||
await openChatRow(p, "chat2")
|
await openChatRow(p, "chat2")
|
||||||
await p.click('#chat-tools [data-terminal-toggle]')
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
// What the page let go of before it loaded again, kept across the load.
|
// What the page let go of before it loaded again, kept across the load.
|
||||||
await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd)))))
|
await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd)))))
|
||||||
await openSettings(p)
|
await openSettings(p)
|
||||||
assert.match(await p.textContent("#set-language option[value='']"), /English/)
|
assert.equal(await p.inputValue("#set-language"), "")
|
||||||
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "de")])
|
assert.match(await p.textContent("#set-language option[value='']"), /Deutsch/)
|
||||||
await p.waitForSelector("#shell:not([hidden])")
|
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "system")])
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "de")
|
|
||||||
assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen")
|
|
||||||
const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]"))
|
|
||||||
assert.ok(before.includes("terminal_close"), "the shell was let go")
|
|
||||||
await openSettings(p)
|
|
||||||
assert.equal(await p.inputValue("#set-language"), "de")
|
|
||||||
assert.match(await p.textContent("#set-look"), /Sprache/)
|
|
||||||
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "")])
|
|
||||||
await p.waitForSelector("#shell:not([hidden])")
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
|
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
|
||||||
|
const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]"))
|
||||||
|
assert.ok(before.includes("terminal_close"), "the shell was let go")
|
||||||
|
await p.waitForTimeout(300)
|
||||||
|
assert.equal(await p.evaluate(() => document.documentElement.lang), "en", "the account's German does not take it back")
|
||||||
|
await openSettings(p)
|
||||||
|
assert.equal(await p.inputValue("#set-language"), "system")
|
||||||
|
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "fr")])
|
||||||
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
|
assert.equal(await p.evaluate(() => document.documentElement.lang), "fr")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+35
-7
@@ -3,9 +3,10 @@
|
|||||||
// script's own table. Do not edit; run the script.
|
// script's own table. Do not edit; run the script.
|
||||||
//
|
//
|
||||||
// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a
|
// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a
|
||||||
// missing entry is the English. The language is the one chosen in Settings (kept in
|
// missing entry is the English. The language is the account's (kept from /me as
|
||||||
// localStorage as `lembas.language`), else the system's (navigator.language), English
|
// `lembas.account-language`) unless Settings chose this computer's or another
|
||||||
// unless it is one of these. Elements marked `data-i18n` (their text) and
|
// (`lembas.language`); the system's is navigator.language. English unless it is one of
|
||||||
|
// these. Elements marked `data-i18n` (their text) and
|
||||||
// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)
|
// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)
|
||||||
// are translated once the page is parsed; this script is deferred, before the page's.
|
// are translated once the page is parsed; this script is deferred, before the page's.
|
||||||
"use strict"
|
"use strict"
|
||||||
@@ -253,6 +254,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}",
|
"Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}",
|
||||||
"Runs": "Spuštění",
|
"Runs": "Spuštění",
|
||||||
"Save": "Uložit",
|
"Save": "Uložit",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.",
|
"Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Řekněte, co se má stát a kdy — „každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení“ — a server to udělá za vás.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Řekněte, co se má stát a kdy — „každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení“ — a server to udělá za vás.",
|
||||||
"Scheduled": "Naplánováno",
|
"Scheduled": "Naplánováno",
|
||||||
@@ -298,6 +300,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Upozornit mě",
|
"Tell me": "Upozornit mě",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde",
|
||||||
"Terminal": "Terminál",
|
"Terminal": "Terminál",
|
||||||
|
"The account's (%(language)s)": "Podle účtu (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:",
|
"The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:",
|
||||||
"The agent asks:": "Agent se ptá:",
|
"The agent asks:": "Agent se ptá:",
|
||||||
@@ -671,6 +674,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}",
|
"Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}",
|
||||||
"Runs": "Läufe",
|
"Runs": "Läufe",
|
||||||
"Save": "Speichern",
|
"Save": "Speichern",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.",
|
"Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Sagen Sie, was wann geschehen soll — „jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen“ — und der Server erledigt es für Sie.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Sagen Sie, was wann geschehen soll — „jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen“ — und der Server erledigt es für Sie.",
|
||||||
"Scheduled": "Geplant",
|
"Scheduled": "Geplant",
|
||||||
@@ -716,6 +720,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Benachrichtigen",
|
"Tell me": "Benachrichtigen",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin",
|
||||||
"Terminal": "Terminal",
|
"Terminal": "Terminal",
|
||||||
|
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:",
|
"The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:",
|
||||||
"The agent asks:": "Der Agent fragt:",
|
"The agent asks:": "Der Agent fragt:",
|
||||||
@@ -1089,6 +1094,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}",
|
"Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}",
|
||||||
"Runs": "Ejecuciones",
|
"Runs": "Ejecuciones",
|
||||||
"Save": "Guardar",
|
"Save": "Guardar",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.",
|
"Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Diga qué debe ocurrir y cuándo —«cada día laborable a las 8:00, resume las noticias de mi lista de lectura»— y el servidor lo hará por usted.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Diga qué debe ocurrir y cuándo —«cada día laborable a las 8:00, resume las noticias de mi lista de lectura»— y el servidor lo hará por usted.",
|
||||||
"Scheduled": "Programado",
|
"Scheduled": "Programado",
|
||||||
@@ -1134,6 +1140,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Avisarme",
|
"Tell me": "Avisarme",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte",
|
||||||
"Terminal": "Terminal",
|
"Terminal": "Terminal",
|
||||||
|
"The account's (%(language)s)": "El de la cuenta (%(language)s)",
|
||||||
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:",
|
"The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:",
|
||||||
"The agent asks:": "El agente pregunta:",
|
"The agent asks:": "El agente pregunta:",
|
||||||
@@ -1507,6 +1514,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}",
|
"Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}",
|
||||||
"Runs": "Exécutions",
|
"Runs": "Exécutions",
|
||||||
"Save": "Enregistrer",
|
"Save": "Enregistrer",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.",
|
"Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Dites ce qui doit se passer et quand — « chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture » — et le serveur s’en charge pour vous.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Dites ce qui doit se passer et quand — « chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture » — et le serveur s’en charge pour vous.",
|
||||||
"Scheduled": "Planifié",
|
"Scheduled": "Planifié",
|
||||||
@@ -1552,6 +1560,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Me prévenir",
|
"Tell me": "Me prévenir",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs",
|
||||||
"Terminal": "Terminal",
|
"Terminal": "Terminal",
|
||||||
|
"The account's (%(language)s)": "Celle du compte (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :",
|
"The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :",
|
||||||
"The agent asks:": "L'agent demande :",
|
"The agent asks:": "L'agent demande :",
|
||||||
@@ -1925,6 +1934,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}",
|
"Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}",
|
||||||
"Runs": "Futások",
|
"Runs": "Futások",
|
||||||
"Save": "Mentés",
|
"Save": "Mentés",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.",
|
"Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Mondja el, mi történjen és mikor — „minden hétköznap 8:00-kor foglald össze az olvasólistám híreit” —, és a szerver elvégzi Ön helyett.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Mondja el, mi történjen és mikor — „minden hétköznap 8:00-kor foglald össze az olvasólistám híreit” —, és a szerver elvégzi Ön helyett.",
|
||||||
"Scheduled": "Ütemezett",
|
"Scheduled": "Ütemezett",
|
||||||
@@ -1970,6 +1980,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Értesítés",
|
"Tell me": "Értesítés",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok",
|
||||||
"Terminal": "Terminál",
|
"Terminal": "Terminál",
|
||||||
|
"The account's (%(language)s)": "A fióké (%(language)s)",
|
||||||
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
"The account's (%(theme)s)": "A fióké (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:",
|
"The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:",
|
||||||
"The agent asks:": "Az ügynök kérdezi:",
|
"The agent asks:": "Az ügynök kérdezi:",
|
||||||
@@ -2343,6 +2354,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}",
|
"Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}",
|
||||||
"Runs": "Esecuzioni",
|
"Runs": "Esecuzioni",
|
||||||
"Save": "Salva",
|
"Save": "Salva",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.",
|
"Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Di’ cosa deve succedere e quando — «ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura» — e il server lo fa per te.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Di’ cosa deve succedere e quando — «ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura» — e il server lo fa per te.",
|
||||||
"Scheduled": "Pianificato",
|
"Scheduled": "Pianificato",
|
||||||
@@ -2388,6 +2400,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Avvisami",
|
"Tell me": "Avvisami",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove",
|
||||||
"Terminal": "Terminale",
|
"Terminal": "Terminale",
|
||||||
|
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:",
|
"The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:",
|
||||||
"The agent asks:": "L'agente chiede:",
|
"The agent asks:": "L'agente chiede:",
|
||||||
@@ -2761,6 +2774,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}",
|
"Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}",
|
||||||
"Runs": "Uruchomienia",
|
"Runs": "Uruchomienia",
|
||||||
"Save": "Zapisz",
|
"Save": "Zapisz",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.",
|
"Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Powiedz, co ma się stać i kiedy — „w każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania” — a serwer zrobi to za Ciebie.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Powiedz, co ma się stać i kiedy — „w każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania” — a serwer zrobi to za Ciebie.",
|
||||||
"Scheduled": "Zaplanowane",
|
"Scheduled": "Zaplanowane",
|
||||||
@@ -2806,6 +2820,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Powiadamiaj",
|
"Tell me": "Powiadamiaj",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej",
|
||||||
"Terminal": "Terminal",
|
"Terminal": "Terminal",
|
||||||
|
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:",
|
"The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:",
|
||||||
"The agent asks:": "Agent pyta:",
|
"The agent asks:": "Agent pyta:",
|
||||||
@@ -3179,6 +3194,7 @@ const LEMBAS_I18N = {
|
|||||||
"Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}",
|
"Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}",
|
||||||
"Runs": "Spustenia",
|
"Runs": "Spustenia",
|
||||||
"Save": "Uložiť",
|
"Save": "Uložiť",
|
||||||
|
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
|
||||||
"Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.",
|
"Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.",
|
||||||
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Povedzte, čo sa má stať a kedy — „každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie“ — a server to urobí za vás.",
|
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Povedzte, čo sa má stať a kedy — „každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie“ — a server to urobí za vás.",
|
||||||
"Scheduled": "Naplánované",
|
"Scheduled": "Naplánované",
|
||||||
@@ -3224,6 +3240,7 @@ const LEMBAS_I18N = {
|
|||||||
"Tell me": "Upozorniť ma",
|
"Tell me": "Upozorniť ma",
|
||||||
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde",
|
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde",
|
||||||
"Terminal": "Terminál",
|
"Terminal": "Terminál",
|
||||||
|
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
|
||||||
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
|
||||||
"The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:",
|
"The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:",
|
||||||
"The agent asks:": "Agent sa pýta:",
|
"The agent asks:": "Agent sa pýta:",
|
||||||
@@ -3365,12 +3382,23 @@ const LEMBAS_SYSTEM_LANG = (() => {
|
|||||||
const code = String(navigator.language || "").slice(0, 2).toLowerCase()
|
const code = String(navigator.language || "").slice(0, 2).toLowerCase()
|
||||||
return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"
|
return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"
|
||||||
})()
|
})()
|
||||||
const LEMBAS_CHOSEN_LANG = (() => {
|
// Settings' choice: "" for the account's language (as /me last said it), "system", or a code.
|
||||||
let code = ""
|
const LEMBAS_CHOICE = (() => {
|
||||||
try {
|
try {
|
||||||
code = localStorage.getItem("lembas.language") || ""
|
return localStorage.getItem("lembas.language") || ""
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const LEMBAS_CHOSEN_LANG = (() => {
|
||||||
|
const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)
|
||||||
|
if (LEMBAS_CHOICE === "system") return ""
|
||||||
|
if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE
|
||||||
|
let account = ""
|
||||||
|
try {
|
||||||
|
account = localStorage.getItem("lembas.account-language") || ""
|
||||||
} catch {}
|
} catch {}
|
||||||
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""
|
return has(account) ? account : ""
|
||||||
})()
|
})()
|
||||||
const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG
|
const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG
|
||||||
document.documentElement.lang = LEMBAS_LANG
|
document.documentElement.lang = LEMBAS_LANG
|
||||||
|
|||||||
+76
-32
@@ -1,16 +1,18 @@
|
|||||||
// The app's language and theme, as chosen in Settings: kept in this app (localStorage), over the
|
// The app's language and theme, as Settings chooses them.
|
||||||
// system's language and the account's theme. The account's own language and theme are the
|
|
||||||
// server's to change (`PATCH /me` has neither yet), so a choice here stays on this computer.
|
|
||||||
//
|
//
|
||||||
// The language is read once, as the page loads (ui/i18n.js, `lembas.language`): every sentence is
|
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
|
||||||
// drawn through `t()` as it is needed, so a new one opens the window again. The theme is applied
|
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
|
||||||
// at once.
|
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
|
||||||
|
// `t()` as it is needed, so a new language opens the window again.
|
||||||
|
//
|
||||||
|
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
|
||||||
|
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
|
||||||
|
|
||||||
import { invoke, letGoAll } from "./bridge.js"
|
import { invoke, patch, letGoAll } from "./bridge.js"
|
||||||
import { terminalThemeChanged } from "./terminal.js"
|
import { terminalThemeChanged } from "./terminal.js"
|
||||||
|
|
||||||
const THEMES = ["moria", "shire"]
|
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
|
||||||
let account = "moria" // the account's theme, as /me says (a custom one counts as Moria)
|
let me = null // /me, as last read
|
||||||
|
|
||||||
function read(key) {
|
function read(key) {
|
||||||
try {
|
try {
|
||||||
@@ -27,21 +29,35 @@ function write(key, value) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The theme chosen in this app ("" for the account's). */
|
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
|
||||||
export function chosenTheme() {
|
function known(code) {
|
||||||
|
const c = String(code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[-_]/)[0]
|
||||||
|
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
|
||||||
|
export function accountThemes() {
|
||||||
|
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
|
||||||
|
export function localTheme() {
|
||||||
const name = read("lembas.theme")
|
const name = read("lembas.theme")
|
||||||
return THEMES.includes(name) ? name : ""
|
return !accountThemes() && LOOKS.includes(name) ? name : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The account's theme, as the server says it. */
|
/** The account's theme, as the server says it. */
|
||||||
export function accountTheme() {
|
export function accountTheme() {
|
||||||
return account
|
return me?.theme || "moria"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The look the window is drawn in: the one chosen here, else `name` (the account's, or Rust's last). */
|
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
|
||||||
export function applyTheme(name) {
|
export function applyTheme(name) {
|
||||||
name = chosenTheme() || name
|
name = localTheme() || name
|
||||||
if (!THEMES.includes(name)) name = "moria"
|
if (!LOOKS.includes(name)) name = "moria"
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.dataset.theme = name
|
root.dataset.theme = name
|
||||||
root.dataset.base = name
|
root.dataset.base = name
|
||||||
@@ -49,29 +65,57 @@ export function applyTheme(name) {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The account's theme is known (from /me): drawn in it, unless one is chosen here. */
|
function paint() {
|
||||||
export function noteAccountTheme(name) {
|
invoke("set_theme", { name: applyTheme(accountTheme()) })
|
||||||
account = THEMES.includes(name) ? name : "moria"
|
|
||||||
invoke("set_theme", { name: applyTheme(account) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose a theme here ("" goes back to the account's): drawn at once, and the window painted in it. */
|
/**
|
||||||
export function chooseTheme(name) {
|
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
|
||||||
write("lembas.theme", THEMES.includes(name) ? name : "")
|
* it is now another, the window opens again in it.
|
||||||
invoke("set_theme", { name: applyTheme(account) })
|
*/
|
||||||
|
export function noteMe(next) {
|
||||||
|
me = next || null
|
||||||
|
paint()
|
||||||
|
if (!me || !me.language) return
|
||||||
|
const account = known(me.language)
|
||||||
|
const before = read("lembas.account-language")
|
||||||
|
write("lembas.account-language", account)
|
||||||
|
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
|
||||||
|
if (read("lembas.account-language") !== account) return
|
||||||
|
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The language chosen in this app ("" for the system's). */
|
/**
|
||||||
|
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
|
||||||
|
* server cannot be told, kept here ("" goes back to the account's).
|
||||||
|
*/
|
||||||
|
export async function chooseTheme(name) {
|
||||||
|
if (accountThemes()) {
|
||||||
|
const r = await patch("/me", { theme: name })
|
||||||
|
me = r && r.theme ? r : { ...me, theme: name }
|
||||||
|
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
|
||||||
|
paint()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
|
||||||
export function chosenLanguage() {
|
export function chosenLanguage() {
|
||||||
return typeof LEMBAS_CHOSEN_LANG === "string" ? LEMBAS_CHOSEN_LANG : ""
|
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose a language here ("" goes back to the system's): the window opens again in it. */
|
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
|
||||||
export async function chooseLanguage(code) {
|
export function accountLanguage() {
|
||||||
if (code === chosenLanguage()) return
|
return me?.language ? known(me.language) : ""
|
||||||
write("lembas.language", code && Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "")
|
}
|
||||||
// The replies followed and the shells open are let go first: the page that opens again has
|
|
||||||
// no handle on them.
|
/** Choose the language; the window opens again in it. */
|
||||||
|
export function chooseLanguage(choice) {
|
||||||
|
if (choice === chosenLanguage()) return
|
||||||
|
write("lembas.language", choice === "system" || known(choice) ? choice : "")
|
||||||
|
return reopen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the window again: the replies followed and the shells open let go first. */
|
||||||
|
async function reopen() {
|
||||||
await letGoAll()
|
await letGoAll()
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
+2
-2
@@ -14,7 +14,7 @@ import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
|||||||
import { initList, refreshList, markCurrent } from "./list.js"
|
import { initList, refreshList, markCurrent } from "./list.js"
|
||||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
|
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
|
||||||
import { applyTheme, noteAccountTheme } from "./look.js"
|
import { applyTheme, noteMe } from "./look.js"
|
||||||
import { initAttach, allowAttach } from "./attach.js"
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
import { initPalette, openPalette } from "./palette.js"
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
@@ -92,7 +92,7 @@ async function startApp() {
|
|||||||
started = true
|
started = true
|
||||||
try {
|
try {
|
||||||
me = await get("/me")
|
me = await get("/me")
|
||||||
noteAccountTheme(me.theme)
|
noteMe(me)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("thread-note").textContent = why(err)
|
$("thread-note").textContent = why(err)
|
||||||
$("thread-note").hidden = false
|
$("thread-note").hidden = false
|
||||||
|
|||||||
+44
-9
@@ -6,7 +6,8 @@
|
|||||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen } from "./bridge.js"
|
import { invoke, listen } from "./bridge.js"
|
||||||
import { agentBlock, onAgentChange, link } from "./agent.js"
|
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||||
import { chosenTheme, chooseTheme, accountTheme, chosenLanguage, chooseLanguage } from "./look.js"
|
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
|
||||||
|
import { why } from "./bridge.js"
|
||||||
|
|
||||||
let folderError = ""
|
let folderError = ""
|
||||||
let current = () => null
|
let current = () => null
|
||||||
@@ -148,27 +149,61 @@ export function renderSettings(s) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The language and the theme: this app's choice over the system's language and the account's theme. */
|
let lookError = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language and the theme. The language: the account's (the default), this computer's, or one
|
||||||
|
* the app has, kept here. The theme: the account's, saved to it; kept here where the server
|
||||||
|
* cannot be told.
|
||||||
|
*/
|
||||||
function lookSection() {
|
function lookSection() {
|
||||||
const named = (code) => LEMBAS_LANGS[code] || code
|
const named = (code) => LEMBAS_LANGS[code] || code
|
||||||
|
const account = accountLanguage()
|
||||||
|
const languages = [
|
||||||
|
["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })],
|
||||||
|
["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })],
|
||||||
|
...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]),
|
||||||
|
]
|
||||||
const language = h(
|
const language = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
|
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
|
||||||
[["", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })], ...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)])].map(([v, label]) =>
|
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
|
||||||
h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v || undefined }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const label = (name) => `${name === "shire" ? "Shire" : "Moria"} — ${name === "shire" ? t("light") : t("dark")}`
|
const scheme = (s) => (s === "light" ? t("light") : t("dark"))
|
||||||
|
const offered = accountThemes()
|
||||||
|
const themes = offered
|
||||||
|
? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`])
|
||||||
|
: [
|
||||||
|
["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })],
|
||||||
|
["moria", `Moria — ${t("dark")}`],
|
||||||
|
["shire", `Shire — ${t("light")}`],
|
||||||
|
]
|
||||||
|
const chosen = offered ? accountTheme() : localTheme()
|
||||||
const theme = h(
|
const theme = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "set-theme", "aria-label": t("Theme"), onchange: (e) => chooseTheme(e.target.value) },
|
{
|
||||||
[["", t("The account's (%(theme)s)", { theme: label(accountTheme()) })], ["moria", label("moria")], ["shire", label("shire")]].map(([v, text]) => h("option", { value: v, text, selected: chosenTheme() === v })),
|
id: "set-theme",
|
||||||
|
"aria-label": t("Theme"),
|
||||||
|
onchange: async (e) => {
|
||||||
|
const el = e.target
|
||||||
|
el.disabled = true
|
||||||
|
try {
|
||||||
|
await chooseTheme(el.value)
|
||||||
|
lookError = ""
|
||||||
|
} catch (err) {
|
||||||
|
lookError = why(err)
|
||||||
|
}
|
||||||
|
renderSettings(current())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })),
|
||||||
)
|
)
|
||||||
return section(
|
return section(
|
||||||
"look",
|
"look",
|
||||||
t("Appearance"),
|
t("Appearance"),
|
||||||
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
|
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
|
||||||
line(t("Theme"), t("Kept in this app on this computer; the account's theme stays as it is."), theme),
|
line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme),
|
||||||
|
lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user