From ae6500cdf1ff06e66489626891a1d2e1a718f376 Mon Sep 17 00:00:00 2001 From: Janlhin Date: Thu, 1 Oct 2026 12:24:30 +0800 Subject: [PATCH 1/3] i18n: skeleton with Settings language picker and Home extracted - lib/i18n.js: zero-dependency t store + t(key, params), locale persisted per device, system-language default, English fallback for missing keys, plurals through Intl.PluralRules - src/locales/en.json (source of truth) + zh-CN.json - Settings > Appearance: language dropdown - Home view extracted (en + zh-CN) - format.js plural() now selects via the chosen locale's plural rules - tests: catalog completeness (no missing/extra keys), fallback, params, plural categories, locale detection; language pinned where tests assert English wording --- cmd/opensave-app/frontend/src/lib/format.js | 12 ++- .../frontend/src/lib/format.test.js | 5 ++ cmd/opensave-app/frontend/src/lib/i18n.js | 87 +++++++++++++++++++ .../frontend/src/lib/i18n.test.js | 85 ++++++++++++++++++ .../frontend/src/lib/timeline.test.js | 5 ++ cmd/opensave-app/frontend/src/locales/en.json | 22 +++++ .../frontend/src/locales/zh-CN.json | 22 +++++ .../frontend/src/views/Home.svelte | 25 +++--- .../views/settings/AppearanceOptions.svelte | 10 +++ 9 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 cmd/opensave-app/frontend/src/lib/i18n.js create mode 100644 cmd/opensave-app/frontend/src/lib/i18n.test.js create mode 100644 cmd/opensave-app/frontend/src/locales/en.json create mode 100644 cmd/opensave-app/frontend/src/locales/zh-CN.json diff --git a/cmd/opensave-app/frontend/src/lib/format.js b/cmd/opensave-app/frontend/src/lib/format.js index a0647b9e..53e33078 100644 --- a/cmd/opensave-app/frontend/src/lib/format.js +++ b/cmd/opensave-app/frontend/src/lib/format.js @@ -1,5 +1,8 @@ // Small formatters shared by the views. +import { get } from 'svelte/store'; +import { locale, pluralRulesFor } from './i18n.js'; + /** Bytes as B, KB, MB or GB, one decimal past bytes: "185 B", "3.2 KB", * "1.4 MB", "9.5 GB". GB so free space on a drive reads as "9.5 GB" and not * "9724.7 MB"; bytes so a small save file is not "0.0 KB". */ @@ -15,8 +18,13 @@ export const fmtSize = (n) => /** A timestamp in the viewer's locale, or a dash for none. */ export const fmtTime = (t) => (t ? new Date(t).toLocaleString() : '—'); -/** "1 game", "3 games". */ -export const plural = (n, word, many = word + 's') => `${n} ${n === 1 ? word : many}`; +/** "1 game", "3 games". The singular/plural choice goes through the chosen + * locale's Intl.PluralRules (English one/other; other languages get their + * own categories once their views are translated). */ +export const plural = (n, word, many = word + 's') => { + const category = pluralRulesFor(get(locale)).select(Number(n ?? 0)); + return `${n} ${category === 'one' ? word : many}`; +}; /** How long something was played: "45 min", "3 h", "14 h 20 min". */ export function playLength(ms) { diff --git a/cmd/opensave-app/frontend/src/lib/format.test.js b/cmd/opensave-app/frontend/src/lib/format.test.js index 0db4bb29..28b795e6 100644 --- a/cmd/opensave-app/frontend/src/lib/format.test.js +++ b/cmd/opensave-app/frontend/src/lib/format.test.js @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; import { fmtSize, fmtTime, playLength, plural } from './format.js'; +import { locale } from './i18n.js'; + +// The plural tests assert English wording; pin the language so the suite does +// not depend on the machine it runs on. +locale.set('en'); describe('fmtSize', () => { it('shows bytes below a kilobyte, KB below a megabyte and MB from one', () => { diff --git a/cmd/opensave-app/frontend/src/lib/i18n.js b/cmd/opensave-app/frontend/src/lib/i18n.js new file mode 100644 index 00000000..6b7233b2 --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/i18n.js @@ -0,0 +1,87 @@ +// A tiny, dependency-free i18n layer. `t` is a Svelte store whose value is a +// translate function: in a component, `$t('home.title')` re-renders when the +// user switches language, with no framework wiring. +// +// Catalogs live in src/locales/ as flat, dot-keyed JSON. `en.json` is the +// source of truth: any key missing from another locale falls back to English, +// so a partial translation never shows a blank. A plural value is an object +// keyed by Intl.PluralRules categories (`{ "one": …, "other": … }`); Chinese +// and other single-form languages just use a string. +import { writable, derived } from 'svelte/store'; + +import en from '../locales/en.json'; +import zhCN from '../locales/zh-CN.json'; + +export const LOCALES = { en: 'English', 'zh-CN': '简体中文' }; + +const CATALOGS = { en, 'zh-CN': zhCN }; +const FALLBACK = 'en'; +const KEY = 'opensave.locale'; + +/** A locale id if valid, otherwise null. */ +export function sanitizeLocale(raw) { + return typeof raw === 'string' && raw in LOCALES ? raw : null; +} + +/** The saved choice if any, else what the system asks for, else English. */ +export function detectLocale(storage = globalThis.localStorage, language = globalThis.navigator?.language) { + try { + const saved = sanitizeLocale(storage?.getItem(KEY)); + if (saved) return saved; + } catch { + // Storage refused (private mode): fall through to the system language. + } + return (language ?? '').toLowerCase().startsWith('zh') ? 'zh-CN' : FALLBACK; +} + +export function loadLocale(storage = globalThis.localStorage) { + return detectLocale(storage); +} + +export function saveLocale(l, storage = globalThis.localStorage) { + try { + storage?.setItem(KEY, l); + } catch { + // Storage refused: the choice lasts until restart. + } +} + +/** The chosen language. Setting it persists immediately. */ +export const locale = writable(loadLocale()); +locale.subscribe((l) => saveLocale(l)); + +/** Plural categories for a locale, as Intl.PluralRules sees them. */ +export function pluralRulesFor(l) { + try { + return new Intl.PluralRules(l); + } catch { + return new Intl.PluralRules(FALLBACK); + } +} + +/** Look a key up with English fallback, resolve plurals, fill {params}. */ +export function translate(catalog, key, params, rules) { + let entry = catalog[key]; + if (entry === undefined) entry = CATALOGS[FALLBACK][key]; + if (entry === undefined) return key; + if (entry !== null && typeof entry === 'object') { + const category = rules.select(Number(params?.n ?? 0)); + entry = entry[category] ?? entry.other ?? key; + // A plural template always mentions {n}; fill it even when the caller + // passed no params at all. + params = { n: 0, ...params }; + } + if (params) { + for (const [name, value] of Object.entries(params)) { + entry = entry.replaceAll(`{${name}}`, String(value)); + } + } + return entry; +} + +/** The translate function, rebound whenever the language changes. */ +export const t = derived(locale, (l) => { + const catalog = CATALOGS[l] ?? {}; + const rules = pluralRulesFor(l); + return (key, params) => translate(catalog, key, params, rules); +}); diff --git a/cmd/opensave-app/frontend/src/lib/i18n.test.js b/cmd/opensave-app/frontend/src/lib/i18n.test.js new file mode 100644 index 00000000..acee81ba --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/i18n.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import en from '../locales/en.json'; +import zhCN from '../locales/zh-CN.json'; +import { LOCALES, detectLocale, pluralRulesFor, sanitizeLocale, translate } from './i18n.js'; + +// Walk the leaf keys of a catalog (a plural value is one leaf). +function leafKeys(obj, prefix = '') { + const keys = []; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object') keys.push(...leafKeys(v, key)); + else keys.push(key); + } + return keys; +} + +describe('locale catalogs', () => { + it('declares every catalog id in LOCALES', () => { + for (const id of ['en', 'zh-CN']) expect(LOCALES[id], id).toBeTruthy(); + }); + + it('has no keys in zh-CN that en.json does not have', () => { + const enKeys = new Set(leafKeys(en)); + for (const key of leafKeys(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true); + }); + + it('has a zh-CN value for every key in en.json', () => { + const zhKeys = new Set(leafKeys(zhCN)); + for (const key of leafKeys(en)) expect(zhKeys.has(key), `missing key: ${key}`).toBe(true); + }); + + it('keeps every en value non-empty', () => { + for (const key of leafKeys(en)) { + const v = en[key]; + expect(typeof v === 'string' || typeof v === 'object', key).toBe(true); + if (typeof v === 'string') expect(v.trim().length, key).toBeGreaterThan(0); + } + }); +}); + +describe('translate', () => { + const rules = pluralRulesFor('en'); + + it('returns the key itself when nothing has it', () => { + expect(translate({}, 'no.such.key', undefined, rules)).toBe('no.such.key'); + }); + + it('falls back to English when the locale lacks the key', () => { + expect(translate({ other: 'x' }, 'home.title', undefined, rules)).toBe('Home'); + }); + + it('fills {params}', () => { + expect(translate({ k: '{game}: got {count} files' }, 'k', { game: 'Hades', count: 7 }, rules)).toBe('Hades: got 7 files'); + }); + + it('selects plural categories and always keeps an "other"', () => { + const catalog = { k: { one: '{n} snapshot', other: '{n} snapshots' } }; + expect(translate(catalog, 'k', { n: 1 }, rules)).toBe('1 snapshot'); + expect(translate(catalog, 'k', { n: 3 }, rules)).toBe('3 snapshots'); + expect(translate(catalog, 'k', undefined, rules)).toBe('0 snapshots'); + }); +}); + +describe('locale choice', () => { + it('rejects unknown locale ids', () => { + expect(sanitizeLocale('fr')).toBe(null); + expect(sanitizeLocale('zh-CN')).toBe('zh-CN'); + }); + + it('prefers the saved choice over the system language', () => { + const storage = { getItem: () => 'en' }; + expect(detectLocale(storage, 'zh-CN')).toBe('en'); + }); + + it('maps a Chinese system language to zh-CN when nothing is saved', () => { + expect(detectLocale({ getItem: () => null }, 'zh-CN')).toBe('zh-CN'); + expect(detectLocale({ getItem: () => null }, 'en-US')).toBe('en'); + }); + + it('survives storage that refuses to read', () => { + const storage = { getItem: () => { throw new Error('blocked'); } }; + expect(detectLocale(storage, 'zh-CN')).toBe('zh-CN'); + }); +}); diff --git a/cmd/opensave-app/frontend/src/lib/timeline.test.js b/cmd/opensave-app/frontend/src/lib/timeline.test.js index eb023f07..5b485cd3 100644 --- a/cmd/opensave-app/frontend/src/lib/timeline.test.js +++ b/cmd/opensave-app/frontend/src/lib/timeline.test.js @@ -1,5 +1,10 @@ import { describe as suite, expect, it } from 'vitest'; import { byDay, describe, filterItems, playedWhere, runs } from './timeline.js'; +import { locale } from './i18n.js'; + +// The expected titles are English; pin the language so the suite does not +// depend on the machine it runs on. +locale.set('en'); const now = new Date(2026, 8, 25, 18, 0); const at = (h, m = 0, day = 25) => new Date(2026, 8, day, h, m).getTime(); diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json new file mode 100644 index 00000000..3b72208e --- /dev/null +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -0,0 +1,22 @@ +{ + "settings.language": "Language", + "home.title": "Home", + "home.scanSaves": "Scan saves", + "home.scanSavesTitle": "Look for game saves on this device", + "home.scanning": "Scanning…", + "home.syncAll": "Sync all", + "home.syncPaused": "Syncing is paused", + "home.trackFolder": "Track folder", + "home.trackFolderTitle": "Pick a save folder or file to track", + "home.chooseFolderDialog": "Choose the save folder to track", + "home.syncAllToast": "Sync triggered for all games", + "home.welcome.title": "Welcome to OpenSave", + "home.welcome.intro": "Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:", + "home.welcome.scanForSaves": "Scan for saves", + "home.welcome.trackManually": "Track a folder manually", + "home.welcome.hintA": "Then open ", + "home.welcome.hintB": " to pair another PC or Steam Deck, or ", + "home.welcome.hintC": " to mirror snapshots online.", + "home.devices": "Devices", + "home.cloudBackup": "Cloud Backup" +} diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json new file mode 100644 index 00000000..d1163d12 --- /dev/null +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -0,0 +1,22 @@ +{ + "settings.language": "语言", + "home.title": "主页", + "home.scanSaves": "扫描存档", + "home.scanSavesTitle": "查找本机上的游戏存档", + "home.scanning": "扫描中…", + "home.syncAll": "全部同步", + "home.syncPaused": "同步已暂停", + "home.trackFolder": "跟踪文件夹", + "home.trackFolderTitle": "选择要跟踪的存档文件夹或文件", + "home.chooseFolderDialog": "选择要跟踪的存档文件夹", + "home.syncAllToast": "已触发全部游戏的同步", + "home.welcome.title": "欢迎使用 OpenSave", + "home.welcome.intro": "让你的游戏存档在所有设备间保持同步——无需账号,不被云端绑架。先找到你的存档:", + "home.welcome.scanForSaves": "扫描存档", + "home.welcome.trackManually": "手动跟踪文件夹", + "home.welcome.hintA": "然后打开 ", + "home.welcome.hintB": " 配对另一台电脑或 Steam Deck,或打开 ", + "home.welcome.hintC": " 将快照镜像到云端。", + "home.devices": "设备", + "home.cloudBackup": "云备份" +} diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index bcfd5091..c21b540a 100644 --- a/cmd/opensave-app/frontend/src/views/Home.svelte +++ b/cmd/opensave-app/frontend/src/views/Home.svelte @@ -14,6 +14,7 @@ import PauseButton from '../components/PauseButton.svelte'; import { setupState, setupSteps, decideSetupFor } from '../lib/setup.js'; import { settings } from '../lib/stores.js'; + import { t } from '../lib/i18n.js'; import ScanSearch from 'lucide-svelte/icons/scan-search'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; import FolderPlus from 'lucide-svelte/icons/folder-plus'; @@ -49,7 +50,7 @@ showAdd = true; return; } - const dir = await native.selectDirectory('Choose the save folder to track'); + const dir = await native.selectDirectory($t('home.chooseFolderDialog')); if (dir) { addPath = dir; showAdd = true; @@ -70,7 +71,7 @@ for (const g of $gameList) { api.post(`/api/games/${g.id}/sync`).catch(() => {}); } - toast('Sync triggered for all games'); + toast($t('home.syncAllToast')); } // Where each game's save stands — the summary and the cards read the same @@ -101,14 +102,14 @@
-

Home

+

{$t('home.title')}

- - + - +
@@ -127,15 +128,15 @@ {:else if $visibleGames.length === 0}
-

Welcome to OpenSave

-

Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:

+

{$t('home.welcome.title')}

+

{$t('home.welcome.intro')}

- +
-

Then open Devices to pair another PC or Steam Deck, or Cloud Backup to mirror snapshots online.

+

{$t('home.welcome.hintA')}{$t('home.devices')}{$t('home.welcome.hintB')}{$t('home.cloudBackup')}{$t('home.welcome.hintC')}

{:else} {#if showGuide} diff --git a/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte b/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte index 24a4bdbd..e9bcc323 100644 --- a/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte +++ b/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte @@ -7,12 +7,22 @@ import MonitorCog from 'lucide-svelte/icons/monitor-cog'; import { appearance, THEMES, ACCENTS, SCALES, onAccent } from '../../lib/appearance.js'; import { systemReducesMotion, slidingIndicator } from '../../lib/motion.js'; + import { locale, LOCALES, t } from '../../lib/i18n.js'; const set = (patch) => appearance.update((v) => ({ ...v, ...patch })); const themeIcons = { dark: Moon, light: Sun, system: MonitorCog };
+ +
Theme
From 5ab46ba337745ccf5108717c4de1fe1f9869e637 Mon Sep 17 00:00:00 2001 From: Janlhin Date: Thu, 1 Oct 2026 19:24:54 +0800 Subject: [PATCH 2/3] i18n: address review feedback - catalog tests compare top-level keys and require an 'other' form on plural values (leaf-walking broke on the first plural) - welcome hint is one key with {devices}/{cloud} placeholders; new segments() helper renders the filled parts in bold - language is 'system' until explicitly picked; only an explicit pick is persisted, and the picker gains a System default option - follows the resolved locale in App.svelte --- cmd/opensave-app/frontend/src/App.svelte | 4 ++ cmd/opensave-app/frontend/src/lib/i18n.js | 60 +++++++++++----- .../frontend/src/lib/i18n.test.js | 70 +++++++++++-------- cmd/opensave-app/frontend/src/locales/en.json | 5 +- .../frontend/src/locales/zh-CN.json | 5 +- .../frontend/src/views/Home.svelte | 8 ++- .../views/settings/AppearanceOptions.svelte | 3 +- 7 files changed, 101 insertions(+), 54 deletions(-) diff --git a/cmd/opensave-app/frontend/src/App.svelte b/cmd/opensave-app/frontend/src/App.svelte index 4b614884..f6eec0e9 100644 --- a/cmd/opensave-app/frontend/src/App.svelte +++ b/cmd/opensave-app/frontend/src/App.svelte @@ -5,6 +5,7 @@ import { applyMessage, wsConnected, view, appUpdate, toast, showAbout, cloudOffers, newGames, navigate, settings, games, availableUpdate } from './lib/stores.js'; import { startController, controllerOn, padUsed, pageStep } from './lib/controller.js'; import { appearance } from './lib/appearance.js'; + import { locale, resolveLocale } from './lib/i18n.js'; import { paletteOpen } from './lib/shortcuts.js'; import { newlyEmptied } from './lib/emptied.js'; import { demandAttention } from './lib/notify.js'; @@ -85,6 +86,9 @@ // with no pointer, it is the only way to see where you are. $: controllerActive = controllerOn($appearance.controller, { deviceType: $settings?.deviceType, used: $padUsed }); $: document.documentElement.dataset.controller = controllerActive ? 'on' : 'off'; + // The document language follows the chosen locale ('system' resolved); a + // wrong lang makes Windows render some Han characters with Japanese shapes. + $: document.documentElement.lang = resolveLocale($locale); onMount(() => startController({ isOn: () => controllerActive, diff --git a/cmd/opensave-app/frontend/src/lib/i18n.js b/cmd/opensave-app/frontend/src/lib/i18n.js index 6b7233b2..7c4ca599 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.js @@ -1,17 +1,24 @@ // A tiny, dependency-free i18n layer. `t` is a Svelte store whose value is a -// translate function: in a component, `$t('home.title')` re-renders when the -// user switches language, with no framework wiring. +// translate function: in a component, `$t('key')` re-renders when the user +// switches language, with no framework wiring. // // Catalogs live in src/locales/ as flat, dot-keyed JSON. `en.json` is the // source of truth: any key missing from another locale falls back to English, // so a partial translation never shows a blank. A plural value is an object // keyed by Intl.PluralRules categories (`{ "one": …, "other": … }`); Chinese // and other single-form languages just use a string. +// +// The language choice is 'system' until the user picks one, and only an +// explicit pick is persisted — so an unpicked app keeps following the OS +// language as it changes. import { writable, derived } from 'svelte/store'; import en from '../locales/en.json'; import zhCN from '../locales/zh-CN.json'; +export const SYSTEM_LOCALE = 'system'; + +/** The languages offered besides the system default. */ export const LOCALES = { en: 'English', 'zh-CN': '简体中文' }; const CATALOGS = { en, 'zh-CN': zhCN }; @@ -20,33 +27,35 @@ const KEY = 'opensave.locale'; /** A locale id if valid, otherwise null. */ export function sanitizeLocale(raw) { - return typeof raw === 'string' && raw in LOCALES ? raw : null; + return typeof raw === 'string' && (raw === SYSTEM_LOCALE || raw in LOCALES) ? raw : null; } -/** The saved choice if any, else what the system asks for, else English. */ -export function detectLocale(storage = globalThis.localStorage, language = globalThis.navigator?.language) { +/** The saved choice, or 'system' when the user has never picked one. */ +export function loadLocale(storage = globalThis.localStorage) { try { - const saved = sanitizeLocale(storage?.getItem(KEY)); - if (saved) return saved; + return sanitizeLocale(storage?.getItem(KEY)) ?? SYSTEM_LOCALE; } catch { - // Storage refused (private mode): fall through to the system language. + return SYSTEM_LOCALE; } - return (language ?? '').toLowerCase().startsWith('zh') ? 'zh-CN' : FALLBACK; -} - -export function loadLocale(storage = globalThis.localStorage) { - return detectLocale(storage); } +/** Persist an explicit pick; 'system' clears it, following the OS again. */ export function saveLocale(l, storage = globalThis.localStorage) { try { - storage?.setItem(KEY, l); + if (l === SYSTEM_LOCALE) storage?.removeItem(KEY); + else storage?.setItem(KEY, l); } catch { // Storage refused: the choice lasts until restart. } } -/** The chosen language. Setting it persists immediately. */ +/** 'system' settled against the OS language, or the explicit pick itself. */ +export function resolveLocale(l, language = globalThis.navigator?.language) { + if (l && l !== SYSTEM_LOCALE) return l; + return (language ?? '').toLowerCase().startsWith('zh') ? 'zh-CN' : FALLBACK; +} + +/** The chosen language. Setting it persists immediately ('system' unpersists). */ export const locale = writable(loadLocale()); locale.subscribe((l) => saveLocale(l)); @@ -81,7 +90,24 @@ export function translate(catalog, key, params, rules) { /** The translate function, rebound whenever the language changes. */ export const t = derived(locale, (l) => { - const catalog = CATALOGS[l] ?? {}; - const rules = pluralRulesFor(l); + const catalog = CATALOGS[resolveLocale(l)] ?? {}; + const rules = pluralRulesFor(resolveLocale(l)); return (key, params) => translate(catalog, key, params, rules); }); + +/** Split a translated template on its {placeholders}, marking which filled + * parts should render in bold. Returns [{ text, bold }, …] in order. */ +export function segments(template, params = {}, boldNames = []) { + const out = []; + const re = /{(\w+)}/g; + let last = 0; + let m; + while ((m = re.exec(template))) { + if (m.index > last) out.push({ text: template.slice(last, m.index), bold: false }); + const name = m[1]; + out.push({ text: String(params[name] ?? m[0]), bold: boldNames.includes(name) }); + last = re.lastIndex; + } + if (last < template.length) out.push({ text: template.slice(last), bold: false }); + return out; +} diff --git a/cmd/opensave-app/frontend/src/lib/i18n.test.js b/cmd/opensave-app/frontend/src/lib/i18n.test.js index acee81ba..efa936f3 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.test.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.test.js @@ -2,18 +2,11 @@ import { describe, expect, it } from 'vitest'; import en from '../locales/en.json'; import zhCN from '../locales/zh-CN.json'; -import { LOCALES, detectLocale, pluralRulesFor, sanitizeLocale, translate } from './i18n.js'; - -// Walk the leaf keys of a catalog (a plural value is one leaf). -function leafKeys(obj, prefix = '') { - const keys = []; - for (const [k, v] of Object.entries(obj)) { - const key = prefix ? `${prefix}.${k}` : k; - if (v !== null && typeof v === 'object') keys.push(...leafKeys(v, key)); - else keys.push(key); - } - return keys; -} +import { LOCALES, loadLocale, pluralRulesFor, resolveLocale, sanitizeLocale, segments, translate } from './i18n.js'; + +// The catalogs are flat, dot-keyed objects; a plural value is one value that +// must expose an "other" form. Comparing top-level keys is enough. +const keysOf = (catalog) => Object.keys(catalog); describe('locale catalogs', () => { it('declares every catalog id in LOCALES', () => { @@ -21,20 +14,26 @@ describe('locale catalogs', () => { }); it('has no keys in zh-CN that en.json does not have', () => { - const enKeys = new Set(leafKeys(en)); - for (const key of leafKeys(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true); + const enKeys = new Set(keysOf(en)); + for (const key of keysOf(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true); }); it('has a zh-CN value for every key in en.json', () => { - const zhKeys = new Set(leafKeys(zhCN)); - for (const key of leafKeys(en)) expect(zhKeys.has(key), `missing key: ${key}`).toBe(true); + const zhKeys = new Set(keysOf(zhCN)); + for (const key of keysOf(en)) expect(zhKeys.has(key), `missing key: ${key}`).toBe(true); }); - it('keeps every en value non-empty', () => { - for (const key of leafKeys(en)) { - const v = en[key]; - expect(typeof v === 'string' || typeof v === 'object', key).toBe(true); - if (typeof v === 'string') expect(v.trim().length, key).toBeGreaterThan(0); + it('keeps every value non-empty, and plurals with an "other" form', () => { + for (const [catalog, name] of [[en, 'en'], [zhCN, 'zh-CN']]) { + for (const [key, v] of Object.entries(catalog)) { + if (v !== null && typeof v === 'object') { + expect(typeof v.other, `${name}:${key} plural needs "other"`).toBe('string'); + expect(v.other.trim().length, `${name}:${key}`).toBeGreaterThan(0); + } else { + expect(typeof v, `${name}:${key}`).toBe('string'); + expect(v.trim().length, `${name}:${key}`).toBeGreaterThan(0); + } + } } }); }); @@ -62,24 +61,39 @@ describe('translate', () => { }); }); +describe('segments', () => { + it('splits a template into plain and bold parts, in order', () => { + expect(segments('open {a} then {b}.', { a: '设备', b: '云备份' }, ['b'])).toEqual([ + { text: 'open ', bold: false }, + { text: '设备', bold: false }, + { text: ' then ', bold: false }, + { text: '云备份', bold: true }, + { text: '.', bold: false } + ]); + }); +}); + describe('locale choice', () => { it('rejects unknown locale ids', () => { expect(sanitizeLocale('fr')).toBe(null); expect(sanitizeLocale('zh-CN')).toBe('zh-CN'); + expect(sanitizeLocale('system')).toBe('system'); }); - it('prefers the saved choice over the system language', () => { - const storage = { getItem: () => 'en' }; - expect(detectLocale(storage, 'zh-CN')).toBe('en'); + it("reports 'system' when the user has never picked one", () => { + expect(loadLocale({ getItem: () => null })).toBe('system'); + expect(loadLocale({ getItem: () => 'zh-CN' })).toBe('zh-CN'); }); - it('maps a Chinese system language to zh-CN when nothing is saved', () => { - expect(detectLocale({ getItem: () => null }, 'zh-CN')).toBe('zh-CN'); - expect(detectLocale({ getItem: () => null }, 'en-US')).toBe('en'); + it('resolves system against the OS language, keeps explicit picks', () => { + expect(resolveLocale('system', 'zh-CN')).toBe('zh-CN'); + expect(resolveLocale('system', 'en-US')).toBe('en'); + expect(resolveLocale('system', '')).toBe('en'); + expect(resolveLocale('zh-CN', 'en-US')).toBe('zh-CN'); }); it('survives storage that refuses to read', () => { const storage = { getItem: () => { throw new Error('blocked'); } }; - expect(detectLocale(storage, 'zh-CN')).toBe('zh-CN'); + expect(loadLocale(storage)).toBe('system'); }); }); diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 3b72208e..92793523 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -1,5 +1,6 @@ { "settings.language": "Language", + "settings.systemDefault": "System default", "home.title": "Home", "home.scanSaves": "Scan saves", "home.scanSavesTitle": "Look for game saves on this device", @@ -14,9 +15,7 @@ "home.welcome.intro": "Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:", "home.welcome.scanForSaves": "Scan for saves", "home.welcome.trackManually": "Track a folder manually", - "home.welcome.hintA": "Then open ", - "home.welcome.hintB": " to pair another PC or Steam Deck, or ", - "home.welcome.hintC": " to mirror snapshots online.", + "home.welcome.hint": "Then open {devices} to pair another PC or Steam Deck, or {cloud} to mirror snapshots online.", "home.devices": "Devices", "home.cloudBackup": "Cloud Backup" } diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json index d1163d12..eb06ba2d 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-CN.json +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -1,5 +1,6 @@ { "settings.language": "语言", + "settings.systemDefault": "跟随系统", "home.title": "主页", "home.scanSaves": "扫描存档", "home.scanSavesTitle": "查找本机上的游戏存档", @@ -14,9 +15,7 @@ "home.welcome.intro": "让你的游戏存档在所有设备间保持同步——无需账号,不被云端绑架。先找到你的存档:", "home.welcome.scanForSaves": "扫描存档", "home.welcome.trackManually": "手动跟踪文件夹", - "home.welcome.hintA": "然后打开 ", - "home.welcome.hintB": " 配对另一台电脑或 Steam Deck,或打开 ", - "home.welcome.hintC": " 将快照镜像到云端。", + "home.welcome.hint": "然后打开 {devices} 配对另一台电脑或 Steam Deck,或打开 {cloud} 将快照镜像到云端。", "home.devices": "设备", "home.cloudBackup": "云备份" } diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index c21b540a..de8ecef4 100644 --- a/cmd/opensave-app/frontend/src/views/Home.svelte +++ b/cmd/opensave-app/frontend/src/views/Home.svelte @@ -14,7 +14,7 @@ import PauseButton from '../components/PauseButton.svelte'; import { setupState, setupSteps, decideSetupFor } from '../lib/setup.js'; import { settings } from '../lib/stores.js'; - import { t } from '../lib/i18n.js'; + import { t, segments } from '../lib/i18n.js'; import ScanSearch from 'lucide-svelte/icons/scan-search'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; import FolderPlus from 'lucide-svelte/icons/folder-plus'; @@ -136,7 +136,11 @@
-

{$t('home.welcome.hintA')}{$t('home.devices')}{$t('home.welcome.hintB')}{$t('home.cloudBackup')}{$t('home.welcome.hintC')}

+

+ {#each segments($t('home.welcome.hint'), { devices: $t('home.devices'), cloud: $t('home.cloudBackup') }, ['devices', 'cloud']) as part} + {#if part.bold}{part.text}{:else}{part.text}{/if} + {/each} +

{:else} {#if showGuide} diff --git a/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte b/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte index e9bcc323..2d86b467 100644 --- a/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte +++ b/cmd/opensave-app/frontend/src/views/settings/AppearanceOptions.svelte @@ -7,7 +7,7 @@ import MonitorCog from 'lucide-svelte/icons/monitor-cog'; import { appearance, THEMES, ACCENTS, SCALES, onAccent } from '../../lib/appearance.js'; import { systemReducesMotion, slidingIndicator } from '../../lib/motion.js'; - import { locale, LOCALES, t } from '../../lib/i18n.js'; + import { locale, LOCALES, t, SYSTEM_LOCALE } from '../../lib/i18n.js'; const set = (patch) => appearance.update((v) => ({ ...v, ...patch })); const themeIcons = { dark: Moon, light: Sun, system: MonitorCog }; @@ -17,6 +17,7 @@
- + (typed = true)} on:keydown={(e) => e.key === 'Enter' && add()} /> - How it shows in your library, and on your other devices. + {$t('home.addGame.nameHint')}
- +
diff --git a/cmd/opensave-app/frontend/src/views/home/HomeSummary.svelte b/cmd/opensave-app/frontend/src/views/home/HomeSummary.svelte index 5e68ce4f..b2713df2 100644 --- a/cmd/opensave-app/frontend/src/views/home/HomeSummary.svelte +++ b/cmd/opensave-app/frontend/src/views/home/HomeSummary.svelte @@ -24,6 +24,7 @@ import { api } from '../../lib/api.js'; import { timeAgo } from '../../lib/timeago.js'; import { libraryView } from '../../lib/libraryview.js'; + import { t } from '../../lib/i18n.js'; import RecentActivity from './RecentActivity.svelte'; /** [{game, status}] for every tracked game, from the library. */ @@ -71,19 +72,19 @@ $: online = paired.filter((p) => p.status === 'online'); $: devices = paired.length === 0 - ? 'None paired' + ? $t('home.summary.nonePaired') : paired.length === 1 - ? `${paired[0].name} · ${online.length ? 'online' : 'offline'}` - : `${online.length} of ${paired.length} online`; + ? `${paired[0].name} · ${online.length ? $t('home.summary.online') : $t('home.summary.offline')}` + : $t('home.summary.devicesOnline', { online: online.length, paired: paired.length }); // Set up, not just switched on: a fresh install has backup on with no // folder chosen, which backs up nothing. $: cloudOn = !!$settings?.cloudSync?.ready; $: cloud = cloudOn - ? (providerById($settings.cloudSync.provider)?.label ?? 'On') + ? (providerById($settings.cloudSync.provider)?.label ?? $t('home.summary.cloudOn')) : $settings?.cloudSync?.enabled - ? 'Not set up' - : 'Off'; + ? $t('home.summary.cloudNotSetUp') + : $t('home.summary.cloudOff'); $: space = spaceUsed(Object.fromEntries(rows.map((r) => [r.game.id, r.game]))); @@ -100,41 +101,41 @@

{summary.headline}

{#if lowSpace !== null}

- Only {fmtSize(lowSpace)} free on the drive your snapshots are kept on — new ones may not fit. - + {$t('home.summary.lowSpace', { size: fmtSize(lowSpace) })} +

{/if} {#if paused} -

- Syncing is paused {pauseLength($syncPause, now)}. Snapshots are still taken, and everything catches up when it - resumes. -

+

{$t('home.summary.pausedDetail', { length: pauseLength($syncPause, now) })}

{/if} {#if paused} - + {/if}
- - -
- Last snapshot - {newest ? timeAgo(newest.at, now) : 'None yet'} +
+ {$t('home.summary.lastSnapshot')} + {newest ? timeAgo(newest.at, now) : $t('home.summary.noneYet')}
diff --git a/cmd/opensave-app/frontend/src/views/home/LibraryGrid.svelte b/cmd/opensave-app/frontend/src/views/home/LibraryGrid.svelte index e122cd85..66c79d5c 100644 --- a/cmd/opensave-app/frontend/src/views/home/LibraryGrid.svelte +++ b/cmd/opensave-app/frontend/src/views/home/LibraryGrid.svelte @@ -12,6 +12,7 @@ // once (e.g. clear a batch of wrongly-tracked entries) without going in and // out of each one. import { navigate, toast } from '../../lib/stores.js'; + import { t } from '../../lib/i18n.js'; import { api } from '../../lib/api.js'; import Camera from 'lucide-svelte/icons/camera'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; @@ -146,13 +147,14 @@ } bulkBusy = false; const done = games.length - failed.length; - if (failed.length) toast(`${verb} ${done} of ${games.length} — ${failed.join('; ')}`, 'error'); - else toast(`${verb} ${done} ${done === 1 ? 'game' : 'games'}`, 'success'); + if (failed.length) toast($t('home.library.bulkPartial', { verb, done, total: games.length, failed: failed.join('; ') }), 'error'); + else toast($t('home.library.bulkDone', { verb, n: done }), 'success'); } - const snapshotPicked = () => forEachPicked('Took a snapshot of', (g) => api.post(`/api/games/${g.id}/snapshot`, { comment: '' })); - const syncPicked = () => forEachPicked('Started syncing', (g) => api.post(`/api/games/${g.id}/sync`, {})); + const snapshotPicked = () => + forEachPicked($t('home.library.verbSnapshot'), (g) => api.post(`/api/games/${g.id}/snapshot`, { comment: '' })); + const syncPicked = () => forEachPicked($t('home.library.verbSync'), (g) => api.post(`/api/games/${g.id}/sync`, {})); const favouritePicked = () => - forEachPicked('Added to Favourites:', (g) => api.post('/api/collections/favourites/games', { gameId: g.id, in: true })); + forEachPicked($t('home.library.verbFavourite'), (g) => api.post('/api/collections/favourites/games', { gameId: g.id, in: true })); // "All" is what is on screen: selecting games a filter is hiding, to then // untrack them, would act on things nobody was looking at. $: allSelected = shown.length > 0 && shown.every((r) => libSelected.has(r.game.id)); @@ -176,30 +178,52 @@ />
-

Library

- +

{$t('home.library.title')}

+
{#if selectMode} - {libSelected.size} selected + {$t('home.library.selectedCount', { n: libSelected.size })} - - - - + {:else}
- Tile size + {$t('home.viewOptions.tileSize')}
{#each Object.entries(TILE_SIZES) as [id, label]}
{#if $libraryView.columns !== 'auto'} -

With a set number per row, the tiles share the width between them.

+

{$t('home.viewOptions.fixedNote')}

{/if}
diff --git a/cmd/opensave-app/frontend/src/views/home/OfferedGames.svelte b/cmd/opensave-app/frontend/src/views/home/OfferedGames.svelte index a69986ec..d039a1cb 100644 --- a/cmd/opensave-app/frontend/src/views/home/OfferedGames.svelte +++ b/cmd/opensave-app/frontend/src/views/home/OfferedGames.svelte @@ -9,6 +9,7 @@ import { gameList, toast } from '../../lib/stores.js'; import { withUndo, hiddenKeys } from '../../lib/undo.js'; import { api, native } from '../../lib/api.js'; + import { t, segments } from '../../lib/i18n.js'; let offeredGames = []; async function load() { @@ -24,11 +25,11 @@ $: if ($gameList) load(); async function place(offer) { - const dir = await native.selectDirectory(`Folder for "${offer.name}" on this device`); + const dir = await native.selectDirectory($t('home.offered.folderPrompt', { name: offer.name })); if (!dir) return; try { await api.post(`/api/offered-games/${offer.gameId}/place`, { path: dir }); - toast(`${offer.name} is now tracked here`, 'success'); + toast($t('home.offered.trackedToast', { name: offer.name }), 'success'); await load(); } catch (e) { toast(e.message, 'error'); @@ -37,7 +38,7 @@ function decline(offer) { withUndo({ - message: `You won't be asked about ${offer.name} again. Tracking it yourself later still works.`, + message: $t('home.offered.declineToast', { name: offer.name }), keys: [`offer:${offer.gameId}`], stillThere: () => offeredGames.some((o) => o.gameId === offer.gameId), run: async () => { @@ -51,23 +52,21 @@ {#if shownOffers.length > 0}
-

Waiting for a folder

-

- {shownOffers.length === 1 ? 'Another device syncs this game' : 'Other devices sync these games'}, - but this one doesn't know where to keep - {shownOffers.length === 1 ? 'it' : 'them'} yet. Nothing syncs until you choose. -

+

{$t('home.offered.title')}

+

{$t('home.offered.intro', { n: shownOffers.length })}

{#each shownOffers as offer (offer.gameId + offer.peerId)}
{offer.name} - - Kept at {offer.peerPath} on the other device. - + {#each segments($t('home.offered.pathHint'), { path: offer.peerPath }, ['path']) as part}{#if part.bold}{part.text}{:else}{part.text}{/if}{/each}
- - + +
{/each} diff --git a/cmd/opensave-app/frontend/src/views/home/RecentActivity.svelte b/cmd/opensave-app/frontend/src/views/home/RecentActivity.svelte index faf9af7c..828316a4 100644 --- a/cmd/opensave-app/frontend/src/views/home/RecentActivity.svelte +++ b/cmd/opensave-app/frontend/src/views/home/RecentActivity.svelte @@ -19,6 +19,7 @@ import { gameCover } from '../../lib/api.js'; import { recentEvents } from '../../lib/overview.js'; import { timeAgo } from '../../lib/timeago.js'; + import { t } from '../../lib/i18n.js'; const MOST = 6; const MIN_WIDTH = 210; @@ -43,12 +44,12 @@
- Recent - + {$t('home.recent.label')} +
{#if events.length === 0} -

Nothing yet. Snapshots and syncs show up here as they happen.

+

{$t('home.recent.empty')}

{:else} {#each shown as e (`${e.game.id}:${e.kind}:${e.at}`)}